Skip to content

Commit f34e200

Browse files
authored
Merge pull request #3189 from useautumn/dev
release
2 parents d8ac3ea + 2deaaae commit f34e200

84 files changed

Lines changed: 14235 additions & 134 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

server/src/cron/invoiceCron/runInvoiceCron.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { addDays } from "date-fns";
44
import { and, asc, eq, isNotNull, lt, or, sql } from "drizzle-orm";
55
import type { Stripe } from "stripe";
66
import { withStatementTimeout } from "@/db/withStatementTimeout.js";
7+
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
8+
import { expirePendingCustomerProducts } from "@/internal/billing/v2/execute/expirePendingCustomerProducts";
79
import { OrgService } from "@/internal/orgs/OrgService";
810
import { createStripeCli } from "../../external/connect/createStripeCli";
911
import { stripeInvoiceToStripeSubscriptionId } from "../../external/stripe/invoices/utils/convertStripeInvoice";
@@ -69,6 +71,22 @@ export const handleVoidInvoiceCron = async ({
6971

7072
const subId = stripeInvoiceToStripeSubscriptionId(invoice);
7173
const voidSub = metadata.type === MetadataType.InvoiceCheckout;
74+
const expirePendingRows = async () => {
75+
try {
76+
await expirePendingCustomerProducts({
77+
ctx: {
78+
db,
79+
logger,
80+
org: { id: org.id },
81+
env: customer.env,
82+
redisV2: resolveRedisV2(),
83+
},
84+
metadataId: metadata.id,
85+
});
86+
} catch (error) {
87+
logger.error(`Error expiring pending customer products: ${error}`);
88+
}
89+
};
7290

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

111+
await expirePendingRows();
93112
await MetadataService.delete({
94113
db,
95114
id: metadata.id,
@@ -123,6 +142,7 @@ export const handleVoidInvoiceCron = async ({
123142
}
124143
}
125144
} else if (invoice.status === "void" || invoice.status === "uncollectible") {
145+
await expirePendingRows();
126146
await MetadataService.delete({
127147
db,
128148
id: metadata.id,

server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,18 @@ export const handleRenewal = async ({
6969
return { success: true };
7070
}
7171

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

78-
await customerProductActions.markActive({
79+
const recoverPastDueProduct = curSameProduct.canceled
80+
? customerProductActions.uncancel
81+
: customerProductActions.markActive;
82+
83+
await recoverPastDueProduct({
7984
ctx: customerCtx,
8085
customerProduct: curSameProduct,
8186
fullCustomer: customer,

server/src/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ const formatCustomerProductStatus = (
111111
const statusLabels: Record<CusProductStatus, string> = {
112112
[CusProductStatus.Active]: "✓ active",
113113
[CusProductStatus.Scheduled]: "⏳ scheduled",
114+
[CusProductStatus.Pending]: "⧖ pending",
114115
[CusProductStatus.Expired]: "✗ expired",
115116
[CusProductStatus.PastDue]: "⚠ past_due",
116117
[CusProductStatus.Trialing]: "🔄 trialing",

server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "@/internal/billing/v2/actions/createSchedule/utils/persistDeferredCreateSchedule";
1818
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
1919
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
20+
import { promotePendingCustomerProducts } from "@/internal/billing/v2/execute/promotePendingCustomerProducts";
2021
import { publishBillingTransition } from "@/internal/billing/v2/publish/publishBillingTransition.js";
2122
import { buildBillingLockKey } from "@/internal/billing/v2/utils/billingLock/buildBillingLockKey";
2223
import { withBillingLock } from "@/internal/billing/v2/utils/billingLock/withBillingLock";
@@ -165,6 +166,13 @@ const executeCheckoutSessionMetadataV2 = async ({
165166
billingContext: updatedDeferredData.billingContext,
166167
});
167168

169+
await promotePendingCustomerProducts({
170+
ctx,
171+
autumnBillingPlan: updatedDeferredData.billingPlan.autumn,
172+
fullCustomer: updatedDeferredData.billingContext.fullCustomer,
173+
metadataId: metadata.id,
174+
});
175+
168176
// Execute autumn billing plan (includes customer products, upsertSubscription, upsertInvoice)
169177
await executeAutumnBillingPlan({
170178
ctx,

server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { CusProductStatus } from "@autumn/shared";
22
import type Stripe from "stripe";
33
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
4+
import {
5+
expireCustomerProducts,
6+
expirePendingCustomerProducts,
7+
} from "@/internal/billing/v2/execute/expirePendingCustomerProducts";
48
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
59
import { MetadataService } from "@/internal/metadata/MetadataService";
610

@@ -32,6 +36,10 @@ export const handleStripeCheckoutSessionExpired = async ({
3236
// Try to clean up the metadata row even if no cusProduct ever got created
3337
// (e.g. a deferred-flow checkout that expired).
3438
if (session.metadata?.autumn_metadata_id) {
39+
await expirePendingCustomerProducts({
40+
ctx,
41+
metadataId: session.metadata.autumn_metadata_id,
42+
});
3543
await MetadataService.delete({
3644
db: ctx.db,
3745
id: session.metadata.autumn_metadata_id,
@@ -40,21 +48,14 @@ export const handleStripeCheckoutSessionExpired = async ({
4048
return;
4149
}
4250

43-
const now = Date.now();
44-
45-
for (const cusProduct of cusProducts) {
46-
// If the success-path webhook already linked a subscription, leave it.
47-
if ((cusProduct.subscription_ids ?? []).length > 0) continue;
51+
const abandonedCusProducts = cusProducts.filter(
52+
(cusProduct) => (cusProduct.subscription_ids ?? []).length === 0,
53+
);
4854

49-
await CusProductService.update({
50-
ctx,
51-
cusProductId: cusProduct.id,
52-
updates: {
53-
status: CusProductStatus.Expired,
54-
ended_at: now,
55-
},
56-
});
57-
}
55+
await expireCustomerProducts({
56+
ctx,
57+
customerProducts: abandonedCusProducts,
58+
});
5859

5960
if (session.metadata?.autumn_metadata_id) {
6061
await MetadataService.delete({

server/src/internal/analytics/analyticsUtils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
ErrCode,
55
type FullCusProduct,
66
type FullCustomer,
7-
type FullCustomerEntitlement,
87
type FullProduct,
98
RecaseError,
109
type Subscription,
@@ -310,6 +309,14 @@ function calculateBillingCycleResult(
310309
const gap = currentEndDate.getTime() - currentStartDate.getTime();
311310
const gapDays = Math.floor(gap / (1000 * 60 * 60 * 24));
312311

312+
if (intervalType === "1bc") {
313+
return {
314+
startDate: startDates[0],
315+
endDate: endDates[0],
316+
gap: gapDays,
317+
};
318+
}
319+
313320
if (intervalType === "last_cycle") {
314321
const earliestCreation = createdDates.reduce((earliest, current) => {
315322
const currentDate = new Date(current);
@@ -339,7 +346,7 @@ function calculateBillingCycleResult(
339346
};
340347
}
341348

342-
const gapMultiplier = intervalType === "1bc" ? 1 : 3;
349+
const gapMultiplier = 3;
343350
const now = new Date();
344351

345352
// For analytics, we look BACKWARD from today for N billing cycles

server/src/internal/billing/v2/actions/generateRequest/compute/buildGenerationPrompts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const buildSystemPrompt = (tool: GenerateBillingTool) => {
2121
"- You cannot ask questions and have no tools. Wherever the docs say to ask, clarify, or call a tool, decide yourself from the most literal reading of the request and produce the single best complete request. Never emit a partial or empty object.",
2222
"- Never omit a required field. Always set plan_id to your best match from the context plans; when sibling variants exist (e.g. monthly vs yearly), pick the one matching the stated interval or amount, defaulting to the monthly variant.",
2323
"- When the request states a price for a plan (e.g. 'at 10k/mo'), always set customize.price to it — including Enterprise/custom placeholder plans where the docs say to ask about the base price.",
24+
"- customer.current_plans[].effective_plan is the subscription's live configuration in the same shape as context.plans. When changing a plan or version, explicitly preserve any current term the request says to keep by copying it into the corresponding customize override.",
2425
"- Use ONLY plan ids and feature ids that appear in the context below.",
2526
"- Monetary amounts are in major currency units (e.g. dollars). Never convert to cents.",
2627
"- The operation always targets the customer in the context. Ignore any other customer mentioned in the request.",

server/src/internal/billing/v2/actions/generateRequest/setup/setupGenerationContext.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
FullProduct,
66
} from "@autumn/shared";
77
import {
8+
cusProductToProduct,
89
mapToProductV2,
910
productV2ToApiPlanV1,
1011
toCreatePlanItemParams,
@@ -30,6 +31,7 @@ const compactPlan = ({
3031
return {
3132
id: productV2.id,
3233
name: productV2.name,
34+
version: productV2.version,
3335
...(productV2.group ? { group: productV2.group } : {}),
3436
...(productV2.is_add_on ? { is_add_on: true } : {}),
3537
...(productV2.free_trial ? { free_trial: productV2.free_trial } : {}),
@@ -94,10 +96,16 @@ export const setupGenerationContext = async ({
9496
customer: {
9597
id: fullCustomer.id,
9698
...(fullCustomer.name ? { name: fullCustomer.name } : {}),
97-
current_plans: fullCustomer.customer_products.map((customerProduct) =>
98-
compactCustomerProduct({
99-
customerProduct,
100-
entities: fullCustomer.entities ?? [],
99+
current_plans: fullCustomer.customer_products.map(
100+
(customerProduct) => ({
101+
...compactCustomerProduct({
102+
customerProduct,
103+
entities: fullCustomer.entities ?? [],
104+
}),
105+
effective_plan: compactPlan({
106+
features,
107+
product: cusProductToProduct({ cusProduct: customerProduct }),
108+
}),
101109
}),
102110
),
103111
entities: (fullCustomer.entities ?? []).map((entity) => ({

server/src/internal/billing/v2/actions/resolveBillingRequest.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,15 @@ export const resolveBillingRequest = async ({
123123
fullCustomer,
124124
params: params.request,
125125
});
126-
fullProduct = cusProductToProduct({
127-
cusProduct: targetCustomerProduct,
128-
});
126+
fullProduct = params.request.version
127+
? await ProductService.getFull({
128+
db: ctx.db,
129+
env: ctx.env,
130+
idOrInternalId: targetCustomerProduct.product.id,
131+
orgId: ctx.org.id,
132+
version: params.request.version,
133+
})
134+
: cusProductToProduct({ cusProduct: targetCustomerProduct });
129135
}
130136

131137
const { request, unrepresentable } = billingParamsV1ToV0({

server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ATTACH_CONFLICT_STATUSES,
23
cusProductToPrices,
34
ErrCode,
45
type FullCusProduct,
@@ -12,7 +13,6 @@ import {
1213
isCustomerProductPaidRecurring,
1314
isCustomerProductRecurring,
1415
isPrepaidPrice,
15-
RELEVANT_STATUSES,
1616
RecaseError,
1717
type UpdateSubscriptionV1Params,
1818
} from "@autumn/shared";
@@ -145,7 +145,7 @@ export const findTargetCustomerProduct = async ({
145145

146146
const candidates = fullCustomerToPlanProducts({ fullCustomer }).filter(
147147
(cp) => {
148-
if (!RELEVANT_STATUSES.includes(cp.status)) return false;
148+
if (!ATTACH_CONFLICT_STATUSES.includes(cp.status)) return false;
149149
return isCusProductOnEntity({ cusProduct: cp, internalEntityId });
150150
},
151151
);
@@ -160,7 +160,7 @@ export const findTargetCustomerProduct = async ({
160160
const fallback = await CusProductService.getFull({
161161
db: ctx.db,
162162
id: params.customer_product_id,
163-
inStatuses: RELEVANT_STATUSES,
163+
inStatuses: ATTACH_CONFLICT_STATUSES,
164164
});
165165
const belongsToCustomer =
166166
fallback?.internal_customer_id === fullCustomer.internal_id;

0 commit comments

Comments
 (0)