fix: get full customer
This commit is contained in:
@@ -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);
|
|
||||||
21
server/experiments/experimentEnv.ts
Normal file
21
server/experiments/experimentEnv.ts
Normal 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");
|
||||||
60
server/experiments/explainGetFull.ts
Normal file
60
server/experiments/explainGetFull.ts
Normal 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();
|
||||||
164
server/experiments/explainListFullProducts.ts
Normal file
164
server/experiments/explainListFullProducts.ts
Normal 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();
|
||||||
@@ -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();
|
|
||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
|
||||||
|
|||||||
Reference in New Issue
Block a user