This commit is contained in:
John Yeo
2026-03-17 12:33:11 +00:00
parent 2250378396
commit bc8d0356f3
13 changed files with 524 additions and 7 deletions

View File

@@ -96,7 +96,7 @@
"js:build": "bun -F @useautumn/sdk build && bun -F autumn-js build",
"js:publish": "bun js:build && cd packages/autumn-js && npm publish",
"js:publish-beta": "bun js:build && cd packages/autumn-js && npm publish --tag beta",
"js:publish": "bun js:build && cd packages/autumn-js && npm publish",
"js:publish-dry": "bun js:build && cd packages/autumn-js && npm publish --dry-run",
"js:version": "git fetch --tags origin && git tag -l 'autumn-js-v*' --sort=-v:refname | head -1"
},

View File

@@ -0,0 +1,181 @@
import {
AllowanceType,
BillingInterval,
CusProductStatus,
FeatureType,
FeatureUsageType,
} from "@autumn/shared";
import { sql } from "drizzle-orm";
import { initDrizzle } from "./experimentEnv";
// Run with `bun run experiments/explainOneOffCleanup.ts`
// Source: getOneOffCustomerProductsToCleanup
// File: server/src/internal/customers/cusProducts/actions/cleanupOneOff/getOneOffToCleanup.ts:48
const RELEVANT_TABLES = [
"customer_products",
"customer_prices",
"customer_entitlements",
"entitlements",
"features",
"prices",
"products",
"customers",
"organizations",
];
const listIndexes = async ({ db }: { db: ReturnType<typeof initDrizzle>["db"] }) => {
console.log("--- Existing indexes on relevant tables ---\n");
const indexResult = await db.execute<{
tablename: string;
indexname: string;
indexdef: string;
}>(sql`
SELECT tablename, indexname, indexdef
FROM pg_indexes
WHERE tablename IN (${sql.join(
RELEVANT_TABLES.map((t) => sql`${t}`),
sql`, `,
)})
ORDER BY tablename, indexname
`);
for (const row of indexResult) {
console.log(`[${row.tablename}] ${row.indexname}`);
console.log(` ${row.indexdef}\n`);
}
};
const main = async () => {
const { db } = initDrizzle();
await listIndexes({ db });
const rawQuery = sql`
WITH
active_cus_products_with_prices AS (
SELECT DISTINCT cp.id
FROM customer_products cp
WHERE cp.status IN (${CusProductStatus.Active}, ${CusProductStatus.PastDue})
AND EXISTS (
SELECT 1 FROM customer_prices cpr WHERE cpr.customer_product_id = cp.id
)
),
cus_products_with_non_one_off_prices AS (
SELECT DISTINCT cpr.customer_product_id
FROM customer_prices cpr
INNER JOIN prices p ON p.id = cpr.price_id
WHERE cpr.customer_product_id IN (SELECT id FROM active_cus_products_with_prices)
AND COALESCE(p.config->>'interval', '') != ${BillingInterval.OneOff}
),
one_off_cus_products AS (
SELECT id FROM active_cus_products_with_prices
WHERE id NOT IN (SELECT customer_product_id FROM cus_products_with_non_one_off_prices)
),
cus_products_with_entitlements AS (
SELECT DISTINCT cp.id
FROM customer_products cp
WHERE cp.id IN (SELECT id FROM one_off_cus_products)
AND EXISTS (
SELECT 1 FROM customer_entitlements ce WHERE ce.customer_product_id = cp.id
)
),
valid_one_off_cus_products AS (
SELECT cp.id, cp.internal_customer_id, cp.internal_entity_id, cp.created_at, cp.internal_product_id
FROM customer_products cp
WHERE cp.id IN (SELECT id FROM cus_products_with_entitlements)
AND NOT EXISTS (
SELECT 1
FROM customer_entitlements ce
INNER JOIN entitlements e ON e.id = ce.entitlement_id
INNER JOIN features f ON f.internal_id = e.internal_feature_id
WHERE ce.customer_product_id = cp.id
AND f.type != ${FeatureType.Boolean}
AND NOT (
COALESCE(f.config->>'usage_type', '') = ${FeatureUsageType.Single}
AND COALESCE(e.allowance_type, '') = ${AllowanceType.Fixed}
AND COALESCE(ce.balance, 0) = 0
AND ce.usage_allowed = false
)
)
),
cus_products_with_newer_active_product AS (
SELECT DISTINCT oo.id
FROM valid_one_off_cus_products oo
INNER JOIN products prod1 ON prod1.internal_id = oo.internal_product_id
WHERE EXISTS (
SELECT 1
FROM customer_products cp2
INNER JOIN products prod2 ON prod2.internal_id = cp2.internal_product_id
WHERE cp2.internal_customer_id = oo.internal_customer_id
AND (
(cp2.internal_entity_id IS NULL AND oo.internal_entity_id IS NULL)
OR cp2.internal_entity_id = oo.internal_entity_id
)
AND prod2.id = prod1.id
AND cp2.created_at > oo.created_at
AND cp2.status IN (${CusProductStatus.Active}, ${CusProductStatus.PastDue})
AND cp2.id != oo.id
AND NOT EXISTS (
SELECT 1
FROM customer_entitlements ce1
INNER JOIN entitlements e1 ON e1.id = ce1.entitlement_id
INNER JOIN features f1 ON f1.internal_id = e1.internal_feature_id
WHERE ce1.customer_product_id = oo.id
AND f1.type = ${FeatureType.Boolean}
AND NOT EXISTS (
SELECT 1
FROM customer_entitlements ce2
INNER JOIN entitlements e2 ON e2.id = ce2.entitlement_id
INNER JOIN features f2 ON f2.internal_id = e2.internal_feature_id
WHERE ce2.customer_product_id = cp2.id
AND f2.id = f1.id
AND f2.type = ${FeatureType.Boolean}
)
)
)
)
SELECT
row_to_json(cp.*) as customer_product,
row_to_json(cpr.*) as customer_price,
row_to_json(p.*) as price,
row_to_json(c.*) as customer,
row_to_json(prod.*) as product,
row_to_json(o.*) as org
FROM customer_products cp
INNER JOIN customer_prices cpr ON cpr.customer_product_id = cp.id
INNER JOIN prices p ON p.id = cpr.price_id
INNER JOIN customers c ON c.internal_id = cp.internal_customer_id
INNER JOIN products prod ON prod.internal_id = cp.internal_product_id
INNER JOIN organizations o ON o.id = c.org_id
WHERE cp.id IN (SELECT id FROM cus_products_with_newer_active_product)
`;
// Run the actual query to measure wall-clock time
console.log("--- Running getOneOffCustomerProductsToCleanup query ---");
const start = performance.now();
const result = await db.execute(rawQuery);
const elapsed = performance.now() - start;
console.log(`Rows returned: ${result.length}`);
console.log(`Wall-clock time: ${elapsed.toFixed(2)}ms\n`);
// Run EXPLAIN ANALYZE
console.log("--- EXPLAIN (ANALYZE, BUFFERS) ---\n");
const explainQuery = sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${rawQuery}`;
const explainResult = await db.execute(explainQuery);
for (const row of explainResult) {
const line = (row as Record<string, unknown>)["QUERY PLAN"];
console.log(line);
}
process.exit(0);
};
await main();

View File

@@ -0,0 +1,205 @@
import {
CusProductStatus,
customerEntitlements,
customerProducts,
customers,
entitlements,
features,
} from "@autumn/shared";
import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
import { initDrizzle } from "./experimentEnv";
// Run with `bun run experiments/explainResetCron.ts`
// Source: CusEntitlementService.getActiveResetPassed
// File: server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts:161
const RELEVANT_TABLES = [
"customer_entitlements",
"entitlements",
"features",
"customers",
"customer_products",
];
const listIndexes = async ({ db }: { db: ReturnType<typeof initDrizzle>["db"] }) => {
console.log("--- Existing indexes on relevant tables ---\n");
const indexResult = await db.execute<{
tablename: string;
indexname: string;
indexdef: string;
}>(sql`
SELECT tablename, indexname, indexdef
FROM pg_indexes
WHERE tablename IN (${sql.join(
RELEVANT_TABLES.map((t) => sql`${t}`),
sql`, `,
)})
ORDER BY tablename, indexname
`);
for (const row of indexResult) {
console.log(`[${row.tablename}] ${row.indexname}`);
console.log(` ${row.indexdef}\n`);
}
};
const main = async () => {
const { db } = initDrizzle({ replica: true });
const now = Date.now();
await listIndexes({ db });
// Reproduce the exact Drizzle query from getActiveResetPassed
const query = db
.select()
.from(customerEntitlements)
.innerJoin(
entitlements,
eq(customerEntitlements.entitlement_id, entitlements.id),
)
.innerJoin(
features,
eq(entitlements.internal_feature_id, features.internal_id),
)
.innerJoin(
customers,
eq(customerEntitlements.internal_customer_id, customers.internal_id),
)
.leftJoin(
customerProducts,
eq(customerEntitlements.customer_product_id, customerProducts.id),
)
.where(
and(
or(
isNull(customerEntitlements.customer_product_id),
eq(customerProducts.status, CusProductStatus.Active),
),
lt(customerEntitlements.next_reset_at, now),
or(
isNull(customerEntitlements.expires_at),
gt(customerEntitlements.expires_at, now),
),
),
)
.limit(5000);
// ── Original query ──────────────────────────────────────────────────
console.log("=== ORIGINAL: getActiveResetPassed (LEFT JOIN + OR) ===\n");
const startOrig = performance.now();
const resultOrig = await db.execute(query.getSQL());
const elapsedOrig = performance.now() - startOrig;
console.log(`Rows returned: ${resultOrig.length}`);
console.log(`Wall-clock time: ${elapsedOrig.toFixed(2)}ms\n`);
console.log("--- EXPLAIN (ANALYZE, BUFFERS) ---\n");
const explainOrig = sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query.getSQL()}`;
const explainOrigResult = await db.execute(explainOrig);
for (const row of explainOrigResult) {
console.log((row as Record<string, unknown>)["QUERY PLAN"]);
}
const expiryFilter = or(
isNull(customerEntitlements.expires_at),
gt(customerEntitlements.expires_at, now),
);
// ── Optimized Sub-query A: Loose entitlements ───────────────────────
console.log(
"\n=== OPTIMIZED A: Loose entitlements (customer_product_id IS NULL) ===\n",
);
const looseQuery = db
.select()
.from(customerEntitlements)
.innerJoin(
entitlements,
eq(customerEntitlements.entitlement_id, entitlements.id),
)
.innerJoin(
features,
eq(entitlements.internal_feature_id, features.internal_id),
)
.innerJoin(
customers,
eq(customerEntitlements.internal_customer_id, customers.internal_id),
)
.where(
and(
isNull(customerEntitlements.customer_product_id),
lt(customerEntitlements.next_reset_at, now),
expiryFilter,
),
)
.limit(5000);
const startLoose = performance.now();
const resultLoose = await db.execute(looseQuery.getSQL());
const elapsedLoose = performance.now() - startLoose;
console.log(`Rows returned: ${resultLoose.length}`);
console.log(`Wall-clock time: ${elapsedLoose.toFixed(2)}ms\n`);
console.log("--- EXPLAIN (ANALYZE, BUFFERS) ---\n");
const explainLoose = sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${looseQuery.getSQL()}`;
const explainLooseResult = await db.execute(explainLoose);
for (const row of explainLooseResult) {
console.log((row as Record<string, unknown>)["QUERY PLAN"]);
}
// ── Optimized Sub-query B: Active product entitlements ──────────────
console.log(
"\n=== OPTIMIZED B: Active product entitlements (INNER JOIN) ===\n",
);
const activeQuery = db
.select()
.from(customerEntitlements)
.innerJoin(
entitlements,
eq(customerEntitlements.entitlement_id, entitlements.id),
)
.innerJoin(
features,
eq(entitlements.internal_feature_id, features.internal_id),
)
.innerJoin(
customers,
eq(customerEntitlements.internal_customer_id, customers.internal_id),
)
.innerJoin(
customerProducts,
sql`${customerEntitlements.customer_product_id} COLLATE "C" = ${customerProducts.id}`,
)
.where(
and(
eq(customerProducts.status, CusProductStatus.Active),
lt(customerEntitlements.next_reset_at, now),
expiryFilter,
),
)
.limit(5000);
const startActive = performance.now();
const resultActive = await db.execute(activeQuery.getSQL());
const elapsedActive = performance.now() - startActive;
console.log(`Rows returned: ${resultActive.length}`);
console.log(`Wall-clock time: ${elapsedActive.toFixed(2)}ms\n`);
console.log("--- EXPLAIN (ANALYZE, BUFFERS) ---\n");
const explainActive = sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${activeQuery.getSQL()}`;
const explainActiveResult = await db.execute(explainActive);
for (const row of explainActiveResult) {
console.log((row as Record<string, unknown>)["QUERY PLAN"]);
}
// ── Summary ─────────────────────────────────────────────────────────
console.log("\n=== SUMMARY ===\n");
console.log(`Original: ${elapsedOrig.toFixed(2)}ms (${resultOrig.length} rows)`);
console.log(`Optimized A: ${elapsedLoose.toFixed(2)}ms (${resultLoose.length} rows)`);
console.log(`Optimized B: ${elapsedActive.toFixed(2)}ms (${resultActive.length} rows)`);
console.log(
`Optimized total: ${(elapsedLoose + elapsedActive).toFixed(2)}ms (${resultLoose.length + resultActive.length} rows)`,
);
process.exit(0);
};
await main();

View File

@@ -0,0 +1,101 @@
import {
CusProductStatus,
customerEntitlements,
customerProducts,
customers,
entitlements,
features,
} from "@autumn/shared";
import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
import { initDrizzle } from "./experimentEnv";
// Run with `bun run experiments/explainResetCronCollation.ts`
// Tests the getActiveResetPassed query with COLLATE "C" fix on the LEFT JOIN.
const RELEVANT_TABLES = ["customer_entitlements", "customer_products"];
const listIndexes = async ({
db,
}: { db: ReturnType<typeof initDrizzle>["db"] }) => {
console.log("--- Existing indexes on relevant tables ---\n");
const indexResult = await db.execute<{
tablename: string;
indexname: string;
indexdef: string;
}>(sql`
SELECT tablename, indexname, indexdef
FROM pg_indexes
WHERE tablename IN (${sql.join(
RELEVANT_TABLES.map((t) => sql`${t}`),
sql`, `,
)})
ORDER BY tablename, indexname
`);
for (const row of indexResult) {
console.log(`[${row.tablename}] ${row.indexname}`);
console.log(` ${row.indexdef}\n`);
}
};
const main = async () => {
const { db } = initDrizzle();
const now = Date.now();
await listIndexes({ db });
const expiryFilter = or(
isNull(customerEntitlements.expires_at),
gt(customerEntitlements.expires_at, now),
);
const query = db
.select()
.from(customerEntitlements)
.innerJoin(
entitlements,
eq(customerEntitlements.entitlement_id, entitlements.id),
)
.innerJoin(
features,
eq(entitlements.internal_feature_id, features.internal_id),
)
.innerJoin(
customers,
eq(customerEntitlements.internal_customer_id, customers.internal_id),
)
.leftJoin(
customerProducts,
sql`${customerEntitlements.customer_product_id} COLLATE "C" = ${customerProducts.id}`,
)
.where(
and(
or(
isNull(customerEntitlements.customer_product_id),
eq(customerProducts.status, CusProductStatus.Active),
),
lt(customerEntitlements.next_reset_at, now),
expiryFilter,
),
)
.limit(5000);
console.log("=== getActiveResetPassed + COLLATE fix ===\n");
const start = performance.now();
const result = await db.execute(query.getSQL());
const elapsed = performance.now() - start;
console.log(`Rows returned: ${result.length}`);
console.log(`Wall-clock time: ${elapsed.toFixed(2)}ms\n`);
console.log("--- EXPLAIN (ANALYZE, BUFFERS) ---\n");
const explainResult = await db.execute(
sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query.getSQL()}`,
);
for (const row of explainResult) {
console.log((row as Record<string, unknown>)["QUERY PLAN"]);
}
process.exit(0);
};
await main();

View File

@@ -1,6 +1,7 @@
import type { DeferredAutumnBillingPlanData } from "@autumn/shared";
import { type Metadata, MetadataType, metadata } from "@autumn/shared";
import { and, eq, isNotNull, lt, or } from "drizzle-orm";
import type { Stripe } from "stripe";
import { OrgService } from "@/internal/orgs/OrgService";
import { createStripeCli } from "../../external/connect/createStripeCli";
import { stripeInvoiceToStripeSubscriptionId } from "../../external/stripe/invoices/utils/convertStripeInvoice";
@@ -55,7 +56,14 @@ export const handleVoidInvoiceCron = async ({
if (!metadata.stripe_invoice_id) return;
const invoice = await stripeCli.invoices.retrieve(metadata.stripe_invoice_id);
let invoice: Stripe.Invoice | undefined;
try {
invoice = await stripeCli.invoices.retrieve(metadata.stripe_invoice_id);
} catch {
logger.warn(`Failed to retrieve invoice ${metadata.stripe_invoice_id}`);
return;
}
const subId = stripeInvoiceToStripeSubscriptionId(invoice);
const voidSub = metadata.type === MetadataType.InvoiceCheckout;
@@ -85,6 +93,17 @@ export const handleVoidInvoiceCron = async ({
});
} catch (error) {
logger.error(`Error voiding invoice: ${error}`);
if (
error instanceof Error &&
error.message.includes("cannot be voided")
) {
await MetadataService.delete({
db,
id: metadata.id,
});
return;
}
logger.error(`Error voiding invoice: ${error}`);
}
} else if (invoice.status === "void" || invoice.status === "uncollectible") {
await MetadataService.delete({

View File

@@ -4,6 +4,7 @@ import type {
StripeCheckoutSessionAction,
} from "@autumn/shared";
import { msToSeconds, orgToReturnUrl } from "@autumn/shared";
import { addMinutes } from "date-fns";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildStripeCheckoutSessionItems } from "@/internal/billing/v2/providers/stripe/utils/checkoutSessions/buildStripeCheckoutSessionItems";
@@ -49,7 +50,7 @@ export const buildStripeCheckoutSessionAction = ({
// 4. Trial handling (only for subscription mode)
const trialEnd =
mode === "subscription" && trialContext?.trialEndsAt
? msToSeconds(trialContext.trialEndsAt)
? msToSeconds(addMinutes(trialContext.trialEndsAt, 10).getTime())
: undefined;
// 5. Build subscription_data (only for subscription mode)

View File

@@ -175,7 +175,7 @@ export class CusEntService {
)
.leftJoin(
customerProducts,
eq(customerEntitlements.customer_product_id, customerProducts.id),
sql`${customerEntitlements.customer_product_id} COLLATE "C" = ${customerProducts.id}`,
)
.where(
and(

View File

@@ -0,0 +1 @@
export const customerEntitlementRepo = {};

View File

@@ -282,6 +282,7 @@ test.concurrent(`${chalk.yellowBright("trial-conversion 5: scheduled downgrade a
balance: 500,
usage: 0,
resetsAt: addMonths(Date.now() + ms.days(14), 1).getTime(),
toleranceMs: ms.hours(1) + ms.minutes(10),
});
// Verify Stripe subscription state

View File

@@ -267,6 +267,7 @@ test.concurrent(`${chalk.yellowBright("multi-attach trial 3: explicit free_trial
customer,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
toleranceMs: ms.hours(1) + ms.minutes(10),
});
expectCustomerFeatureCorrect({

View File

@@ -404,6 +404,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: paid no trial -> paid with tri
customer,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
toleranceMs: ms.hours(1) + ms.minutes(10),
});
// Feature should still have correct values with usage preserved
@@ -595,6 +596,7 @@ test.concurrent(`${chalk.yellowBright("p2f-trial: paid no trial -> free with tri
customer,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
toleranceMs: ms.hours(1) + ms.minutes(10),
});
// Usage should be preserved, reset should follow new trial end

View File

@@ -1,5 +1,10 @@
import { expect, test } from "bun:test";
import { type ApiCustomerV3, BillingVersion } from "@autumn/shared";
import {
type ApiCustomerV3,
BillingVersion,
FreeTrialDuration,
ms,
} from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductNotPresent } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
@@ -13,7 +18,6 @@ import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import { FreeTrialDuration } from "@autumn/shared";
import chalk from "chalk";
import { CusService } from "@/internal/customers/CusService";
@@ -47,6 +51,7 @@ test.concurrent(`${chalk.yellowBright("paid-defaults: trial product")}`, async (
customer,
productId: trialDefault.id,
trialEndsAt: calculateTrialEndMs({ trialDays: 14 }),
toleranceMs: ms.hours(1) + ms.minutes(10),
});
expectCustomerFeatureCorrect({

View File

@@ -176,7 +176,7 @@ export const advanceToNextInvoice = async ({
stripeCli,
testClockId,
advanceTo: addMonths(baseTime, 1).getTime(),
waitForSeconds: parallel ? 60 : 30,
waitForSeconds: parallel ? 80 : 50,
});
await advanceTestClock({