diff --git a/server/experiments/batchDelete.ts b/server/experiments/batchDelete.ts deleted file mode 100644 index 657ddc583..000000000 --- a/server/experiments/batchDelete.ts +++ /dev/null @@ -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); \ No newline at end of file diff --git a/server/experiments/experimentEnv.ts b/server/experiments/experimentEnv.ts new file mode 100644 index 000000000..03717c591 --- /dev/null +++ b/server/experiments/experimentEnv.ts @@ -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"); diff --git a/server/experiments/explainGetFull.ts b/server/experiments/explainGetFull.ts new file mode 100644 index 000000000..50c7ef5a6 --- /dev/null +++ b/server/experiments/explainGetFull.ts @@ -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)["QUERY PLAN"]; + console.log(line); + } + + process.exit(0); +}; + +await main(); diff --git a/server/experiments/explainListFullProducts.ts b/server/experiments/explainListFullProducts.ts new file mode 100644 index 000000000..5a792a9e3 --- /dev/null +++ b/server/experiments/explainListFullProducts.ts @@ -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`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)["QUERY PLAN"]; + console.log(line); + } + + process.exit(0); +}; + +await main(); diff --git a/server/experiments/rateLimiting.ts b/server/experiments/rateLimiting.ts deleted file mode 100644 index 1f502eea2..000000000 --- a/server/experiments/rateLimiting.ts +++ /dev/null @@ -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[]) => { - 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(); \ No newline at end of file diff --git a/server/experiments/revenuecat.ts b/server/experiments/revenuecat.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/src/honoMiddlewares/analyticsMiddleware.ts b/server/src/honoMiddlewares/analyticsMiddleware.ts index 16f7eb9a5..65d2ea6c9 100644 --- a/server/src/honoMiddlewares/analyticsMiddleware.ts +++ b/server/src/honoMiddlewares/analyticsMiddleware.ts @@ -37,7 +37,8 @@ const extractCustomerIdFromBody = ({ method: string; }): string | undefined => { 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 | string | undefined; @@ -114,11 +115,14 @@ const logResponse = async ({ const log = c.res.status === 200 ? ctx.logger.info : ctx.logger.warn; const statusColor = c.res.status === 200 ? chalk.green : chalk.yellow; - log(`[${statusColor(c.res.status)}] ${c.req.path} (${ctx.org?.slug}) ${durationMs}ms`, { - statusCode: c.res.status, - durationMs, - res: responseBody, - }); + log( + `[${statusColor(c.res.status)}] ${c.req.path} (${ctx.org?.slug}) ${durationMs}ms`, + { + statusCode: c.res.status, + durationMs, + res: responseBody, + }, + ); if ( Object.keys(ctx.extraLogs).length > 0 && diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts index 6d92104d4..0272b9334 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts @@ -59,6 +59,12 @@ export const buildStripeSubscriptionUpdateAction = ({ typeof subscriptionCancelAt === "number" && subscriptionCancelAt !== currentCancelAt; + // Configure trial settings off + const shouldUpdateEndBehavior = + shouldUnsetTrialEnd && + stripeSubscription.trial_settings?.end_behavior.missing_payment_method !== + "create_invoice"; + const params: Stripe.SubscriptionUpdateParams = { items: subItemsUpdate.length > 0 ? subItemsUpdate : undefined, trial_end: shouldSetTrialEnd @@ -76,6 +82,14 @@ export const buildStripeSubscriptionUpdateAction = ({ ...(stripeDiscounts?.length && { discounts: stripeDiscountsToParams({ stripeDiscounts }), }), + + ...(shouldUpdateEndBehavior && { + trial_settings: { + end_behavior: { + missing_payment_method: "create_invoice", + }, + }, + }), }; const hasNoUpdates = [ diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index e350ed0f1..2ce9c7430 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -44,6 +44,7 @@ export class CusService { withSubs = false, allowNotFound = false, withEvents = false, + explain = false, }: { ctx: AutumnContext; idOrInternalId: string; @@ -54,6 +55,7 @@ export class CusService { withSubs?: boolean; allowNotFound?: boolean; withEvents?: boolean; + explain?: boolean; }): Promise { const { db, org, env } = ctx; const orgId = org.id; @@ -86,6 +88,12 @@ export class CusService { 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); if (!result || result.length === 0) { @@ -452,13 +460,15 @@ export class CusService { const ids = batch.map((r) => r.internal_id); - await db.delete(customers).where( - and( - inArray(customers.internal_id, ids), - eq(customers.org_id, orgId), - eq(customers.env, env), - ), - ); + await db + .delete(customers) + .where( + and( + inArray(customers.internal_id, ids), + eq(customers.org_id, orgId), + eq(customers.env, env), + ), + ); } } diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 6c111df77..6f58a1356 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -78,7 +78,7 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { FROM free_trials ft WHERE ft.id = cp.free_trial_id ) 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()} ) `; @@ -217,7 +217,7 @@ const buildExtraEntitlementsCTE = () => { '[]'::json ) AS extra_customer_entitlements 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.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) ) @@ -619,7 +619,7 @@ export const getPaginatedFullCusQuery = ({ ${ withSubs - ? sql`, customer_subscriptions AS ( + ? sql`, customer_subscriptions AS ( SELECT s.internal_customer_id, COALESCE( @@ -683,7 +683,7 @@ export const getPaginatedFullCusQuery = ({ ${ includeInvoices - ? sql`, customer_invoices AS ( + ? sql`, customer_invoices AS ( SELECT cr.internal_id AS internal_customer_id, COALESCE( diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index b915422f5..740e2dcfa 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -1,43 +1,68 @@ -import { expect, test } from "bun:test"; -import { type ApiCustomerV3, CustomerExpand } from "@autumn/shared"; +import { test } from "bun:test"; +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 { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils"; -test("temp: create entity updates cached customer", async () => { - const customerId = `temp-cached-customer-${Date.now()}`; - const entityId = `${customerId}-entity-1`; +test("temp: paid default trial customer can upgrade to premium", async () => { + const customerId = `temp-default-trial-upgrade`; - 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, - setup: [s.customer({ testClock: false })], + setup: [ + s.customer({ testClock: true, withDefault: true }), + s.products({ list: [defaultTrial, premium] }), + ], actions: [], }); - await autumnV1.customers.get(customerId); // set customer in the cache + const customerBeforeUpgrade = + await autumnV1.customers.get(customerId); - await autumnV1.entitiesV2.create({ - customer_id: customerId, - entity_id: entityId, - name: "Temp Entity", - feature_id: TestFeature.Users, + await expectProductTrialing({ + customer: customerBeforeUpgrade, + productId: defaultTrial.id, + trialEndsAt: calculateTrialEndMs({ trialDays: 7 }), }); - const customerFromCache = await autumnV1.customers.get( - customerId, - { - expand: [CustomerExpand.Entities], - }, - ); + expectCustomerFeatureCorrect({ + customer: customerBeforeUpgrade, + featureId: TestFeature.Messages, + 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( - (entity) => entity.id === entityId, - ); - - expect(createdEntity).toBeDefined(); - expect(createdEntity).toMatchObject({ - id: entityId, - name: "Temp Entity", + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 12, }); }); diff --git a/shared/models/productModels/freeTrialModels/freeTrialTable.ts b/shared/models/productModels/freeTrialModels/freeTrialTable.ts index d110b81e2..3bc3b4d24 100644 --- a/shared/models/productModels/freeTrialModels/freeTrialTable.ts +++ b/shared/models/productModels/freeTrialModels/freeTrialTable.ts @@ -1,6 +1,7 @@ import { boolean, foreignKey, + index, numeric, pgTable, text, @@ -25,5 +26,6 @@ export const freeTrials = pgTable( foreignColumns: [products.internal_id], name: "free_trials_internal_product_id_fkey", }).onDelete("cascade"), + index("idx_free_trials_internal_product_id").on(table.internal_product_id), ], ); diff --git a/shared/models/productModels/productTable.ts b/shared/models/productModels/productTable.ts index cc6348a4a..fea898712 100644 --- a/shared/models/productModels/productTable.ts +++ b/shared/models/productModels/productTable.ts @@ -8,7 +8,7 @@ import { text, unique, } from "drizzle-orm/pg-core"; -import { collatePgColumn, sqlNow } from "../../db/utils"; +import { sqlNow } from "../../db/utils"; import { organizations } from "../orgModels/orgTable"; type ProductProcessor = { @@ -48,5 +48,3 @@ export const products = pgTable( ), ], ); - -collatePgColumn(products.internal_id, "C");