fix: get full customer

This commit is contained in:
John Yeo
2026-03-15 15:22:09 +00:00
parent a2686b3acd
commit 0f1611c2aa
13 changed files with 346 additions and 187 deletions

View File

@@ -1,17 +0,0 @@
import { CusEntService } from "../src/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { initDrizzle } from "../src/db/initDrizzle";
import { clearCusEntsFromCache } from "../src/cron/resetCron/clearCusEntsFromCache";
const main = async () => {
const { db } = initDrizzle();
const cusEnts = await CusEntService.getActiveResetPassed({
db,
batchSize: 500,
});
await clearCusEntsFromCache({ cusEnts });
};
await main();
process.exit(0);

View File

@@ -0,0 +1,21 @@
import "dotenv/config";
import { loadLocalEnv } from "../src/utils/envUtils";
loadLocalEnv();
const requireEnv = ({ key }: { key: string }) => {
const value = process.env[key];
if (!value) {
throw new Error(`${key} env var is required`);
}
return value;
};
export const prodTestOrgId = requireEnv({ key: "PROD_TEST_ORG_ID" });
export const prodTestCustomerId = requireEnv({
key: "PROD_TEST_CUSTOMER_ID",
});
export const { initDrizzle } = await import("../src/db/initDrizzle");

View File

@@ -0,0 +1,60 @@
import { AppEnv, CusProductStatus } from "@autumn/shared";
import { sql } from "drizzle-orm";
import {
initDrizzle,
prodTestCustomerId,
prodTestOrgId,
} from "./experimentEnv";
const { getFullCusQuery } = await import(
"../src/internal/customers/getFullCusQuery"
);
const RELEVANT_STATUSES = [
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Scheduled,
];
// Run with `bun run experiments/explainGetFull.ts`
const main = async () => {
const orgId = prodTestOrgId;
const env = AppEnv.Live;
const customerId = prodTestCustomerId;
const { db } = initDrizzle();
const query = getFullCusQuery(
customerId,
orgId,
env,
RELEVANT_STATUSES,
true, // includeInvoices
true, // withEntities
false, // withTrialsUsed
true, // withSubs
false, // withEvents
);
// Run the actual query to measure wall-clock time
console.log("--- Running query ---");
const start = performance.now();
const result = await db.execute(query);
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) ${query}`;
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,164 @@
import { AppEnv, entitlements, freeTrials, prices, products } from "@autumn/shared";
import { and, eq, exists, sql } from "drizzle-orm";
import { initDrizzle, prodTestOrgId } from "./experimentEnv";
const main = async () => {
const orgId = prodTestOrgId;
const env = AppEnv.Live;
const { db } = initDrizzle();
// Reproduce the latestVersionsSubquery from ProductService._listFullQuery
const latestVersionsSubquery = db
.select({
id: products.id,
maxVersion: sql<number>`MAX(${products.version})`.as("max_version"),
})
.from(products)
.where(and(eq(products.org_id, orgId), eq(products.env, env)))
.groupBy(products.id)
.as("latest_versions");
// 1. Run the Drizzle relational query for wall-clock time
console.log("--- Running ProductService.listFull query ---");
const start = performance.now();
const data = await db.query.products.findMany({
where: and(
eq(products.org_id, orgId),
eq(products.env, env),
exists(
db
.select()
.from(latestVersionsSubquery)
.where(
and(
eq(latestVersionsSubquery.id, products.id),
eq(latestVersionsSubquery.maxVersion, products.version),
),
),
),
),
with: {
entitlements: {
with: { feature: true },
where: eq(entitlements.is_custom, false),
},
prices: { where: eq(prices.is_custom, false) },
free_trials: { where: eq(freeTrials.is_custom, false) },
},
});
const elapsed = performance.now() - start;
console.log(`Products returned: ${data.length}`);
console.log(`Wall-clock time: ${elapsed.toFixed(2)}ms\n`);
// 2. Build equivalent raw SQL so we can wrap with EXPLAIN ANALYZE
const rawQuery = sql`
SELECT
p.internal_id, p.id, p.name, p.description, p.org_id,
p.created_at, p.env, p.is_add_on, p.is_default, p."group",
p.version, p.processor, p.base_variant_id, p.archived,
ent_data.data AS entitlements,
price_data.data AS prices,
ft_data.data AS free_trials
FROM products p
LEFT JOIN LATERAL (
SELECT COALESCE(
json_agg(json_build_object(
'id', e.id,
'created_at', e.created_at,
'internal_feature_id', e.internal_feature_id,
'internal_product_id', e.internal_product_id,
'is_custom', e.is_custom,
'allowance_type', e.allowance_type,
'allowance', e.allowance,
'interval', e."interval",
'interval_count', e.interval_count,
'carry_from_previous', e.carry_from_previous,
'entity_feature_id', e.entity_feature_id,
'org_id', e.org_id,
'feature_id', e.feature_id,
'usage_limit', e.usage_limit,
'rollover', e.rollover,
'feature', row_to_json(f)
)),
'[]'::json
) AS data
FROM entitlements e
LEFT JOIN features f ON f.internal_id = e.internal_feature_id
WHERE e.internal_product_id = p.internal_id
AND e.is_custom = false
) ent_data ON true
LEFT JOIN LATERAL (
SELECT COALESCE(
json_agg(json_build_object(
'id', pr.id,
'org_id', pr.org_id,
'internal_product_id', pr.internal_product_id,
'config', pr.config,
'created_at', pr.created_at,
'billing_type', pr.billing_type,
'tier_behavior', pr.tier_behavior,
'is_custom', pr.is_custom,
'entitlement_id', pr.entitlement_id,
'proration_config', pr.proration_config
)),
'[]'::json
) AS data
FROM prices pr
WHERE pr.internal_product_id = p.internal_id
AND pr.is_custom = false
) price_data ON true
LEFT JOIN LATERAL (
SELECT COALESCE(
json_agg(json_build_object(
'id', ft.id,
'created_at', ft.created_at,
'internal_product_id', ft.internal_product_id,
'duration', ft.duration,
'length', ft.length,
'unique_fingerprint', ft.unique_fingerprint,
'is_custom', ft.is_custom,
'card_required', ft.card_required
)),
'[]'::json
) AS data
FROM free_trials ft
WHERE ft.internal_product_id = p.internal_id
AND ft.is_custom = false
) ft_data ON true
WHERE p.org_id = ${orgId}
AND p.env = ${env}
AND EXISTS (
SELECT id, max_version FROM (
SELECT id, MAX(version) AS max_version
FROM products
WHERE org_id = ${orgId} AND env = ${env}
GROUP BY id
) latest_versions
WHERE latest_versions.id = p.id
AND max_version = p.version
)
`;
// Run raw query for wall-clock time
console.log("--- Running raw SQL query ---");
const start2 = performance.now();
const rawResult = await db.execute(rawQuery);
const elapsed2 = performance.now() - start2;
console.log(`Rows returned: ${rawResult.length}`);
console.log(`Wall-clock time: ${elapsed2.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

@@ -1,122 +0,0 @@
import { ApiVersion } from "@autumn/shared";
import AutumnError, { AutumnInt } from "../src/external/autumn/autumnCli";
export const main = async () => {
console.log("🚀 Starting rate limit test...\n");
// Initialize client
const autumn = new AutumnInt({version: ApiVersion.V1_2});
const numRequests = 100;
const customerId1 = "trial1";
const customerId2 = "temp";
console.log(`Testing with ${numRequests} concurrent requests to /track endpoint`);
console.log(`Using base URL: ${autumn.baseUrl}\n`);
// Create all track requests
const cusId1Promises = [];
const cusId2Promises = [];
for (let i = 0; i < numRequests; i++) {
cusId1Promises.push(
autumn.entities.get(customerId1, "1"),
);
cusId2Promises.push(
autumn.entities.get(customerId2, "1"),
);
}
// Execute all requests concurrently for both customers
const startTime = Date.now();
const [cusId1Results, cusId2Results] = await Promise.all([
Promise.allSettled(cusId1Promises),
Promise.allSettled(cusId2Promises),
]);
const duration = Date.now() - startTime;
// Helper function to analyze results
const analyzeResults = (results: PromiseSettledResult<unknown>[]) => {
const succeeded = results.filter((r) => r.status === "fulfilled").length;
const rateLimited = results.filter(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
).length;
const otherErrors = results.filter(
(r) =>
r.status === "rejected" &&
!(
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded"
),
).length;
return { succeeded, rateLimited, otherErrors };
};
const cus1Stats = analyzeResults(cusId1Results);
const cus2Stats = analyzeResults(cusId2Results);
const totalRequests = numRequests * 2;
// Display results
console.log("📊 Results:");
console.log("═".repeat(60));
console.log(`Total requests: ${totalRequests} (${numRequests} per customer)`);
console.log(`⏱️ Duration: ${duration}ms`);
console.log(`📈 Throughput: ${Math.round(totalRequests / (duration / 1000))} req/s`);
console.log("═".repeat(60));
console.log(`\n👤 Customer 1 (${customerId1}):`);
console.log("─".repeat(60));
console.log(` Total: ${numRequests}`);
console.log(` ✅ Succeeded: ${cus1Stats.succeeded}`);
console.log(` ⛔ Rate limited: ${cus1Stats.rateLimited}`);
console.log(` ❌ Other errors: ${cus1Stats.otherErrors}`);
console.log(`\n👤 Customer 2 (${customerId2}):`);
console.log("─".repeat(60));
console.log(` Total: ${numRequests}`);
console.log(` ✅ Succeeded: ${cus2Stats.succeeded}`);
console.log(` ⛔ Rate limited: ${cus2Stats.rateLimited}`);
console.log(` ❌ Other errors: ${cus2Stats.otherErrors}`);
console.log("\n📈 Combined Stats:");
console.log("─".repeat(60));
console.log(
` ✅ Total succeeded: ${cus1Stats.succeeded + cus2Stats.succeeded}`,
);
console.log(
` ⛔ Total rate limited: ${cus1Stats.rateLimited + cus2Stats.rateLimited}`,
);
console.log(
` ❌ Total errors: ${cus1Stats.otherErrors + cus2Stats.otherErrors}`,
);
// Show sample errors if any
const totalErrors = cus1Stats.otherErrors + cus2Stats.otherErrors;
if (totalErrors > 0) {
console.log("\n⚠ Sample of other errors:");
const errorSamples = [...cusId1Results, ...cusId2Results]
.filter(
(r) =>
r.status === "rejected" &&
!(
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded"
),
)
.slice(0, 3);
for (const sample of errorSamples) {
if (sample.status === "rejected") {
console.log(` - ${sample.reason}`);
}
}
}
console.log("\n✨ Test complete!");
};
await main();

View File

@@ -37,7 +37,8 @@ const extractCustomerIdFromBody = ({
method: string; method: string;
}): string | undefined => { }): string | undefined => {
const isCreateCustomerPath = const isCreateCustomerPath =
path.startsWith("/v1/customers") && method === "POST"; (path.startsWith("/v1/customers") && method === "POST") ||
path.includes("customers.get_or_create");
return (isCreateCustomerPath ? body?.id : body?.customer_id) as return (isCreateCustomerPath ? body?.id : body?.customer_id) as
| string | string
| undefined; | undefined;
@@ -114,11 +115,14 @@ const logResponse = async ({
const log = c.res.status === 200 ? ctx.logger.info : ctx.logger.warn; const log = c.res.status === 200 ? ctx.logger.info : ctx.logger.warn;
const statusColor = c.res.status === 200 ? chalk.green : chalk.yellow; const statusColor = c.res.status === 200 ? chalk.green : chalk.yellow;
log(`[${statusColor(c.res.status)}] ${c.req.path} (${ctx.org?.slug}) ${durationMs}ms`, { log(
statusCode: c.res.status, `[${statusColor(c.res.status)}] ${c.req.path} (${ctx.org?.slug}) ${durationMs}ms`,
durationMs, {
res: responseBody, statusCode: c.res.status,
}); durationMs,
res: responseBody,
},
);
if ( if (
Object.keys(ctx.extraLogs).length > 0 && Object.keys(ctx.extraLogs).length > 0 &&

View File

@@ -59,6 +59,12 @@ export const buildStripeSubscriptionUpdateAction = ({
typeof subscriptionCancelAt === "number" && typeof subscriptionCancelAt === "number" &&
subscriptionCancelAt !== currentCancelAt; subscriptionCancelAt !== currentCancelAt;
// Configure trial settings off
const shouldUpdateEndBehavior =
shouldUnsetTrialEnd &&
stripeSubscription.trial_settings?.end_behavior.missing_payment_method !==
"create_invoice";
const params: Stripe.SubscriptionUpdateParams = { const params: Stripe.SubscriptionUpdateParams = {
items: subItemsUpdate.length > 0 ? subItemsUpdate : undefined, items: subItemsUpdate.length > 0 ? subItemsUpdate : undefined,
trial_end: shouldSetTrialEnd trial_end: shouldSetTrialEnd
@@ -76,6 +82,14 @@ export const buildStripeSubscriptionUpdateAction = ({
...(stripeDiscounts?.length && { ...(stripeDiscounts?.length && {
discounts: stripeDiscountsToParams({ stripeDiscounts }), discounts: stripeDiscountsToParams({ stripeDiscounts }),
}), }),
...(shouldUpdateEndBehavior && {
trial_settings: {
end_behavior: {
missing_payment_method: "create_invoice",
},
},
}),
}; };
const hasNoUpdates = [ const hasNoUpdates = [

View File

@@ -44,6 +44,7 @@ export class CusService {
withSubs = false, withSubs = false,
allowNotFound = false, allowNotFound = false,
withEvents = false, withEvents = false,
explain = false,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
idOrInternalId: string; idOrInternalId: string;
@@ -54,6 +55,7 @@ export class CusService {
withSubs?: boolean; withSubs?: boolean;
allowNotFound?: boolean; allowNotFound?: boolean;
withEvents?: boolean; withEvents?: boolean;
explain?: boolean;
}): Promise<FullCustomer> { }): Promise<FullCustomer> {
const { db, org, env } = ctx; const { db, org, env } = ctx;
const orgId = org.id; const orgId = org.id;
@@ -86,6 +88,12 @@ export class CusService {
entityId, entityId,
); );
if (explain) {
const explainQuery = sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`;
const result = await db.execute(explainQuery);
return result as unknown as FullCustomer;
}
const result = await db.execute(query); const result = await db.execute(query);
if (!result || result.length === 0) { if (!result || result.length === 0) {
@@ -452,13 +460,15 @@ export class CusService {
const ids = batch.map((r) => r.internal_id); const ids = batch.map((r) => r.internal_id);
await db.delete(customers).where( await db
and( .delete(customers)
inArray(customers.internal_id, ids), .where(
eq(customers.org_id, orgId), and(
eq(customers.env, env), inArray(customers.internal_id, ids),
), eq(customers.org_id, orgId),
); eq(customers.env, env),
),
);
} }
} }

View File

@@ -78,7 +78,7 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => {
FROM free_trials ft FROM free_trials ft
WHERE ft.id = cp.free_trial_id WHERE ft.id = cp.free_trial_id
) ft_data ON true ) ft_data ON true
WHERE cp.internal_customer_id = (SELECT internal_id FROM customer_record) WHERE cp.internal_customer_id = (SELECT internal_id COLLATE "default" FROM customer_record)
${withStatusFilter()} ${withStatusFilter()}
) )
`; `;
@@ -217,7 +217,7 @@ const buildExtraEntitlementsCTE = () => {
'[]'::json '[]'::json
) AS extra_customer_entitlements ) AS extra_customer_entitlements
FROM customer_entitlements ce FROM customer_entitlements ce
WHERE ce.internal_customer_id = (SELECT internal_id FROM customer_record) WHERE ce.internal_customer_id = (SELECT internal_id COLLATE "default" FROM customer_record)
AND ce.customer_product_id IS NULL AND ce.customer_product_id IS NULL
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
) )
@@ -619,7 +619,7 @@ export const getPaginatedFullCusQuery = ({
${ ${
withSubs withSubs
? sql`, customer_subscriptions AS ( ? sql`, customer_subscriptions AS (
SELECT SELECT
s.internal_customer_id, s.internal_customer_id,
COALESCE( COALESCE(
@@ -683,7 +683,7 @@ export const getPaginatedFullCusQuery = ({
${ ${
includeInvoices includeInvoices
? sql`, customer_invoices AS ( ? sql`, customer_invoices AS (
SELECT SELECT
cr.internal_id AS internal_customer_id, cr.internal_id AS internal_customer_id,
COALESCE( COALESCE(

View File

@@ -1,43 +1,68 @@
import { expect, test } from "bun:test"; import { test } from "bun:test";
import { type ApiCustomerV3, CustomerExpand } from "@autumn/shared"; import { type ApiCustomerV3, tryCatch } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import {
calculateTrialEndMs,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { TestFeature } from "@tests/setup/v2Features"; import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils";
test("temp: create entity updates cached customer", async () => { test("temp: paid default trial customer can upgrade to premium", async () => {
const customerId = `temp-cached-customer-${Date.now()}`; const customerId = `temp-default-trial-upgrade`;
const entityId = `${customerId}-entity-1`;
const { autumnV1 } = await initScenario({ const defaultTrial = products.defaultTrial({
id: "default-trial",
items: [items.monthlyMessages({ includedUsage: 500 })],
trialDays: 7,
cardRequired: false,
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1, ctx, testClockId } = await initScenario({
customerId, customerId,
setup: [s.customer({ testClock: false })], setup: [
s.customer({ testClock: true, withDefault: true }),
s.products({ list: [defaultTrial, premium] }),
],
actions: [], actions: [],
}); });
await autumnV1.customers.get<ApiCustomerV3>(customerId); // set customer in the cache const customerBeforeUpgrade =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await autumnV1.entitiesV2.create({ await expectProductTrialing({
customer_id: customerId, customer: customerBeforeUpgrade,
entity_id: entityId, productId: defaultTrial.id,
name: "Temp Entity", trialEndsAt: calculateTrialEndMs({ trialDays: 7 }),
feature_id: TestFeature.Users,
}); });
const customerFromCache = await autumnV1.customers.get<ApiCustomerV3>( expectCustomerFeatureCorrect({
customerId, customer: customerBeforeUpgrade,
{ featureId: TestFeature.Messages,
expand: [CustomerExpand.Entities], includedUsage: 500,
}, balance: 500,
); usage: 0,
});
expect(customerFromCache.entities).toBeDefined(); try {
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
// redirect_mode: "redirect_mode",
});
} catch (error) {}
const createdEntity = customerFromCache.entities?.find( await advanceTestClock({
(entity) => entity.id === entityId, stripeCli: ctx.stripeCli,
); testClockId: testClockId!,
numberOfDays: 12,
expect(createdEntity).toBeDefined();
expect(createdEntity).toMatchObject({
id: entityId,
name: "Temp Entity",
}); });
}); });

View File

@@ -1,6 +1,7 @@
import { import {
boolean, boolean,
foreignKey, foreignKey,
index,
numeric, numeric,
pgTable, pgTable,
text, text,
@@ -25,5 +26,6 @@ export const freeTrials = pgTable(
foreignColumns: [products.internal_id], foreignColumns: [products.internal_id],
name: "free_trials_internal_product_id_fkey", name: "free_trials_internal_product_id_fkey",
}).onDelete("cascade"), }).onDelete("cascade"),
index("idx_free_trials_internal_product_id").on(table.internal_product_id),
], ],
); );

View File

@@ -8,7 +8,7 @@ import {
text, text,
unique, unique,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { collatePgColumn, sqlNow } from "../../db/utils"; import { sqlNow } from "../../db/utils";
import { organizations } from "../orgModels/orgTable"; import { organizations } from "../orgModels/orgTable";
type ProductProcessor = { type ProductProcessor = {
@@ -48,5 +48,3 @@ export const products = pgTable(
), ),
], ],
); );
collatePgColumn(products.internal_id, "C");