This commit is contained in:
John Yeo
2025-11-01 20:13:22 +00:00
parent 5ab888a9b8
commit bfb6494657
20 changed files with 315 additions and 87 deletions

View File

@@ -49,6 +49,7 @@
"@clickhouse/client": "^1.11.2",
"@date-fns/tz": "^1.2.0",
"@date-fns/utc": "^2.1.0",
"@hono-rate-limiter/redis": "^0.1.4",
"@hono/node-server": "^1.19.5",
"@hono/zod-validator": "^0.7.3",
"@hyperbrowser/sdk": "^0.54.0",
@@ -93,6 +94,7 @@
"express-rate-limit": "^7.5.1",
"fetch-retry": "^6.0.0",
"hono": "^4.9.9",
"hono-rate-limiter": "^0.4.2",
"http-status-codes": "^2.3.0",
"ioredis": "^5.5.0",
"ksuid": "^3.0.0",
@@ -544,6 +546,8 @@
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
"@hono-rate-limiter/redis": ["@hono-rate-limiter/redis@0.1.4", "", { "peerDependencies": { "hono-rate-limiter": "^0.2.1" } }, "sha512-RSrVX5N2Oo/xXApskegu667cBVHyr8RXGWnbRDGjU2py8pN4BttEKSHA0iKi3BAwh1xSkENgDRng4tpFD9DbKg=="],
"@hono/node-server": ["@hono/node-server@1.19.5", "", { "peerDependencies": { "hono": "^4" } }, "sha512-iBuhh+uaaggeAuf+TftcjZyWh2GEgZcVGXkNtskLVoWaXhnJtC5HLHrU8W1KHDoucqO1MswwglmkWLFyiDn4WQ=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.4", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-biKGn3BRJVaftZlIPMyK+HCe/UHAjJ6sH0UyXe3+v0OcgVr9xfImDROTJFLtn9e3XEEAHGZIM9U6evu85abm8Q=="],
@@ -1228,7 +1232,7 @@
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
"@types/bunyan": ["@types/bunyan@1.8.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ=="],
@@ -2008,6 +2012,8 @@
"hono": ["hono@4.10.1", "", {}, "sha512-rpGNOfacO4WEPClfkEt1yfl8cbu10uB1lNpiI33AKoiAHwOS8lV748JiLx4b5ozO/u4qLjIvfpFsPXdY5Qjkmg=="],
"hono-rate-limiter": ["hono-rate-limiter@0.4.2", "", { "peerDependencies": { "hono": "^4.1.1" } }, "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw=="],
"html-minifier-terser": ["html-minifier-terser@6.1.0", "", { "dependencies": { "camel-case": "^4.1.2", "clean-css": "^5.2.2", "commander": "^8.3.0", "he": "^1.2.0", "param-case": "^3.0.4", "relateurl": "^0.2.7", "terser": "^5.10.0" }, "bin": { "html-minifier-terser": "cli.js" } }, "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw=="],
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
@@ -3366,6 +3372,8 @@
"@types/body-parser/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="],
"@types/bun/bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
"@types/bunyan/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="],
"@types/chai-http/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="],

View File

@@ -33,6 +33,7 @@
"@clickhouse/client": "^1.11.2",
"@date-fns/tz": "^1.2.0",
"@date-fns/utc": "^2.1.0",
"@hono-rate-limiter/redis": "^0.1.4",
"@hono/node-server": "^1.19.5",
"@hono/zod-validator": "^0.7.3",
"@hyperbrowser/sdk": "^0.54.0",
@@ -77,6 +78,7 @@
"express-rate-limit": "^7.5.1",
"fetch-retry": "^6.0.0",
"hono": "^4.9.9",
"hono-rate-limiter": "^0.4.2",
"http-status-codes": "^2.3.0",
"ioredis": "^5.5.0",
"ksuid": "^3.0.0",

View File

@@ -10,7 +10,7 @@ export const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });
export const initDrizzle = (params?: { maxConnections?: number }) => {
const maxConnections = params?.maxConnections;
const maxConnections = params?.maxConnections || 20;
const client = postgres(process.env.DATABASE_URL!, {
max: maxConnections,
});

View File

@@ -97,12 +97,20 @@ export class AutumnInt {
});
if (response.status !== 200) {
// Handle rate limit errors
if (response.status === 429) {
throw new AutumnError({
message: `request failed, rate limit exceeded`,
code: "rate_limit_exceeded",
});
}
let error: any;
try {
error = await response.json();
} catch (error) {
throw new AutumnError({
message: `AutumnInt post request failed, error: ${error}`,
message: `request failed, error: ${error}`,
code: ErrCode.InternalError,
});
}

View File

@@ -0,0 +1,92 @@
import type { Context } from "hono";
import { rateLimiter } from "hono-rate-limiter";
import type { HonoEnv } from "../honoUtils/HonoEnv.js";
/**
* General rate limiter for all API routes: 50k requests/second per organization
*/
export const generalRateLimiter = rateLimiter({
windowMs: 1000, // 1 second
limit: 100_000,
standardHeaders: "draft-6",
keyGenerator: (c: Context<HonoEnv>) => {
const ctx = c.var.ctx;
if (!ctx?.org?.id) {
return "anonymous";
}
return `org:${ctx.org.id}:${ctx.env}`;
},
handler: (c: Context<HonoEnv>) => {
return c.json(
{
message: "Too many requests. Please try again later.",
code: "rate_limit_exceeded",
},
429,
);
},
});
/**
* Factory function to create customer-based rate limiters
* Key format: customer_id:org_id:env
*/
const createCustomerRateLimiter = ({ limit }: { limit: number }) => {
return rateLimiter({
windowMs: 1000, // 1 second
limit,
standardHeaders: "draft-6",
keyGenerator: async (c: Context<HonoEnv>) => {
const ctx = c.var.ctx;
// Try to get customer_id from request body
let customerId: string | undefined;
try {
const bodyObj = await c.req.json();
customerId = bodyObj?.customer_id;
} catch {
// If we can't parse the body, fall back to org-level limiting
}
if (!customerId || !ctx?.org?.id || !ctx?.env) {
// Fall back to org-level limiting if customer info not available
return ctx?.org?.id
? `org:${ctx.org.id}:${ctx.env || "unknown"}`
: "anonymous";
}
return `customer:${customerId}:${ctx.org.id}:${ctx.env}`;
},
handler: (c: Context<HonoEnv>) => {
return c.json(
{
message: "Too many requests. Please try again later.",
code: "rate_limit_exceeded",
},
429,
);
},
// store: new RedisStore({
// client: new Redis({
// url: process.env.UPSTASH_URL!,
// token: process.env.UPSTASH_TOKEN!,
// }),
// }),
});
};
/**
* Rate limiter for /track and /events endpoints: 1k requests/second per customer
*/
export const customerTrackRateLimiter = createCustomerRateLimiter({
limit: 10_000,
});
/**
* Rate limiter for /check and /entitled endpoints: 100k requests/second per customer
*/
export const customerCheckRateLimiter = createCustomerRateLimiter({
limit: 100_000,
});

View File

@@ -9,6 +9,11 @@ import { betterAuthMiddleware } from "./honoMiddlewares/betterAuthMiddleware.js"
import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
import { orgConfigMiddleware } from "./honoMiddlewares/orgConfigMiddleware.js";
import { queryMiddleware } from "./honoMiddlewares/queryMiddleware.js";
import {
customerCheckRateLimiter,
customerTrackRateLimiter,
generalRateLimiter,
} from "./honoMiddlewares/rateLimitMiddleware.js";
import { refreshCacheMiddleware } from "./honoMiddlewares/refreshCacheMiddleware.js";
import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js";
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
@@ -93,13 +98,16 @@ export const createHonoApp = () => {
app.use("/v1/*", analyticsMiddleware);
app.use("/v1/*", queryMiddleware());
// API Routes
app.post("/v1/events", ...handleTrack);
app.post("/v1/track", ...handleTrack);
app.post("/v1/usage", ...handleSetUsage);
// General org rate limiter for all other /v1/* routes
app.use("/v1/*", generalRateLimiter);
app.post("/v1/entitled", ...handleCheck);
app.post("/v1/check", ...handleCheck);
// Track/Check endpoints use customer-specific rate limiters instead of general org limiter
app.post("/v1/events", customerTrackRateLimiter, ...handleTrack);
app.post("/v1/track", customerTrackRateLimiter, ...handleTrack);
app.post("/v1/entitled", customerCheckRateLimiter, ...handleCheck);
app.post("/v1/check", customerCheckRateLimiter, ...handleCheck);
app.post("/v1/usage", ...handleSetUsage);
app.route("v1/customers", cusRouter);
app.route("v1/products", honoProductRouter);
app.route("v1/platform", platformBetaRouter);

View File

@@ -1,4 +1,5 @@
import {
ApiVersion,
InsufficientBalanceError,
SuccessCode,
TrackParamsSchema,
@@ -64,7 +65,8 @@ export const handleTrack = createRoute({
event_name: body.event_name,
};
return c.json(response);
if (ctx.apiVersion.gte(ApiVersion.V1_1)) return c.json(response);
return c.json({ success: true });
} catch (error) {
if (error instanceof InsufficientBalanceError) {
return c.json({

View File

@@ -82,6 +82,7 @@ const deductFromCusEnts = async ({
cusProducts: fullCus.customer_products,
featureIds: relevantFeatures.map((f) => f.id),
reverseOrder: org.config?.reverse_deduction_order,
entity: fullCus.entity,
});
const { unlimited } = getUnlimitedAndUsageAllowed({

View File

@@ -1,29 +1,29 @@
import {
cusProductToEnts,
type FullCusProduct,
type FullCustomer,
formatAmount,
getTotalCusProdQuantity,
isTrialing,
type Organization,
type Price,
type UsagePriceConfig,
} from "@autumn/shared";
import { logger } from "better-auth";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { priceToInvoiceDescription } from "@/internal/invoices/invoiceFormatUtils.js";
import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import {
Price,
FullCusProduct,
Organization,
formatAmount,
UsagePriceConfig,
getTotalCusProdQuantity,
FullCustomer,
} from "@autumn/shared";
import { logger } from "better-auth";
import Stripe from "stripe";
import { isTrialing } from "@autumn/shared";
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import {
getPriceEntitlement,
getPriceOptions,
} from "@/internal/products/prices/priceUtils.js";
import { cusProductToEnts } from "@autumn/shared";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getUnusedAmountAfterDiscount } from "@/internal/rewards/rewardUtils.js";
import { Decimal } from "decimal.js";
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
const getDiscountsApplied = ({
invoiceItem,
@@ -35,7 +35,7 @@ const getDiscountsApplied = ({
if (!invoiceItem || !subDiscounts) return [];
const discountsApplied: Stripe.Discount[] = [];
for (const dAmount of invoiceItem?.discount_amounts || []) {
const discount = subDiscounts?.find((d) => d.id == dAmount.discount);
const discount = subDiscounts?.find((d) => d.id === dAmount.discount);
if (discount && dAmount.amount > 0) {
// console.log("Discount applied: ", discount.id);
// console.log("Amount off: ", dAmount.amount);
@@ -53,6 +53,7 @@ export const priceToUnusedPreviewItem = ({
org,
subDiscounts,
latestInvoice,
anchor,
}: {
customer?: FullCustomer;
price: Price;
@@ -62,6 +63,7 @@ export const priceToUnusedPreviewItem = ({
org?: Organization;
subDiscounts?: Stripe.Discount[];
latestInvoice?: Stripe.Invoice;
anchor?: number;
}) => {
now = now || Date.now();
const onTrial = isTrialing({ cusProduct, now });
@@ -104,12 +106,15 @@ export const priceToUnusedPreviewItem = ({
interval: price.config.interval!,
intervalCount: price.config.interval_count || 1,
},
anchor: subItem?.current_period_end
? subItem.current_period_end * 1000
: undefined,
anchor: anchor,
})!;
if (finalProration) {
console.log(
`Proration start: ${formatUnixToDate(finalProration.start)}, end: ${formatUnixToDate(finalProration.end)}`,
);
}
let amount = onTrial
? 0
: -priceToInvoiceAmount({

View File

@@ -1,11 +1,10 @@
import { notNullish } from "@/utils/genUtils.js";
import {
Entity,
EntityWithFeature,
Feature,
FullCusEntWithFullCusProduct,
FullCustomerEntitlement,
type Entity,
type Feature,
type FullCusEntWithFullCusProduct,
type FullCustomerEntitlement,
} from "@autumn/shared";
import { notNullish } from "@/utils/genUtils.js";
export const cusEntMatchesEntity = ({
cusEnt,
@@ -32,7 +31,7 @@ export const cusEntMatchesEntity = ({
if (notNullish(cusEnt.entitlement.entity_feature_id)) {
entityFeatureIdMatch =
cusEnt.entitlement.entity_feature_id == entity.feature_id;
cusEnt.entitlement.entity_feature_id === entity.feature_id;
}
return cusProductMatch && entityFeatureIdMatch;
@@ -55,7 +54,7 @@ export const findMainCusEntForFeature = ({
cusEnts: FullCustomerEntitlement[];
feature: Feature;
}) => {
let mainCusEnt = cusEnts.find(
const mainCusEnt = cusEnts.find(
(e: any) => e.entitlement.feature.internal_id === feature.internal_id,
);
@@ -88,12 +87,12 @@ export const findCusEnt = ({
features?: Feature[];
}) => {
return cusEnts.find((ce: any) => {
let featureMatch =
const featureMatch =
ce.entitlement.feature.internal_id === feature.internal_id;
let entityMatch = cusEntMatchesEntity({ cusEnt: ce, entity, features });
const entityMatch = cusEntMatchesEntity({ cusEnt: ce, entity, features });
let usageMatch = onlyUsageAllowed ? ce.usage_allowed : true;
const usageMatch = onlyUsageAllowed ? ce.usage_allowed : true;
return featureMatch && entityMatch && usageMatch;
});

View File

@@ -44,6 +44,7 @@ export const getItemsForCurProduct = async ({
let items: PreviewLineItem[] = [];
const subItems = sub?.items.data || [];
const curPrices = cusProductToPrices({ cusProduct: curCusProduct });
// const anchor = sub?.billing_cycle_anchor ? sub.billing_cycle_anchor * 1000 : undefined;
for (const price of curPrices) {
if (isArrearPrice({ price }) || isContUsePrice({ price })) {
@@ -51,6 +52,9 @@ export const getItemsForCurProduct = async ({
}
const previewLineItem = priceToUnusedPreviewItem({
anchor: sub?.billing_cycle_anchor
? sub.billing_cycle_anchor * 1000
: undefined,
price,
stripeItems: subItems,
cusProduct: curCusProduct,

View File

@@ -19,7 +19,10 @@ import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { attachParamsToCurCusProduct } from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
import { getContUseInvoiceItems } from "@/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.js";
import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js";
import {
getAlignedUnix,
getPeriodStartForEnd,
} from "@/internal/products/prices/billingIntervalUtils2.js";
import {
priceToFeature,
priceToUsageModel,
@@ -103,7 +106,17 @@ export const getProration = ({
}
let start = proration?.start;
if (!start && end) {
if (!start && end && anchor) {
// Find the period start by iterating from the anchor until we reach the period that contains 'now'
// This ensures we get the correct period even when the anchor day doesn't exist in some months
// e.g., anchor=31 Oct, now=14 Nov, end=30 Nov -> start should be 31 Oct, not 30 Oct
start = getPeriodStartForEnd({
anchor,
intervalConfig,
targetEnd: end,
});
} else if (!start && end) {
// Fallback to old behavior if no anchor is provided
start = subtractIntervalForProration({
unixTimestamp: end!,
interval,

View File

@@ -59,6 +59,38 @@ export const subtractIntervalFromAnchor = ({
return now;
};
// Finds the period start by advancing from anchor until reaching the period that contains targetEnd
// Returns the start of the period that ends at or after targetEnd
export const getPeriodStartForEnd = ({
anchor,
intervalConfig,
targetEnd,
}: {
anchor: number;
intervalConfig: IntervalConfig;
targetEnd: number;
}) => {
let periodStart = anchor;
let periodEnd = addIntervalForProration({
unixTimestamp: anchor,
intervalConfig,
});
// Keep advancing until we find the period containing targetEnd
const maxIterations = 50;
let iterations = 0;
while (periodEnd < targetEnd && iterations < maxIterations) {
periodStart = periodEnd;
periodEnd = addIntervalForProration({
unixTimestamp: periodEnd,
intervalConfig,
});
iterations++;
}
return periodStart;
};
export const getAlignedUnix = ({
anchor,
intervalConfig,

View File

@@ -4,18 +4,17 @@ import { AutumnInt } from "./external/autumn/autumnCli.js";
const main = async () => {
const autumn = new AutumnInt({ secretKey: process.env.JDEV! });
const concurrency = 1;
const concurrency = 1000;
const promises = [];
for (let i = 0; i < concurrency; i++) {
const simulateTrack = async () => {
const start = Date.now();
const response = await autumn.track({
await autumn.track({
customer_id: "john",
feature_id: "credits",
value: 350,
entity_id: "entity_2",
value: 1,
});
console.log(response);
const end = Date.now();
console.log(`Track ${i} took ${end - start}ms`);
return {
@@ -24,13 +23,24 @@ const main = async () => {
};
promises.push(simulateTrack());
}
const results = await Promise.all(promises);
const results = await Promise.allSettled(promises);
const latencies = results.map((r) => r.latency);
const latencies = results
.filter((r) => r.status === "fulfilled")
.map((r) => r.value.latency);
const p99Latency = latencies.sort((a, b) => a - b)[
Math.floor(latencies.length * 0.99)
];
console.log(`P99 latency: ${p99Latency}ms`);
const rejectedCount = results.filter((r) => r.status === "rejected").length;
console.log(`Rejected count: ${rejectedCount}`);
for (const result of results) {
if (result.status === "rejected") {
console.error((result.reason as any).message);
}
}
};
main()

View File

@@ -51,7 +51,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro diff enti
},
{
id: "2",
name: "Entity 1",
name: "Entity 2",
feature_id: TestFeature.Users,
},
];
@@ -128,8 +128,6 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro diff enti
value: entity2Usage,
});
await timeout(3000);
const entity1Res = await autumn.entities.get(customerId, entity1.id);
const entity2Res = await autumn.entities.get(customerId, entity2.id);

View File

@@ -1,34 +1,33 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
AppEnv,
type AppEnv,
BillingInterval,
Organization,
type Organization,
ProductItemInterval,
ProductV2,
type ProductV2,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts } from "../utils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { replaceItems } from "../utils.js";
import { timeout } from "@/utils/genUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addWeeks } from "date-fns";
import type Stripe from "stripe";
import { setupBefore } from "tests/before.js";
import { defaultApiVersion } from "tests/constants.js";
import { runMigrationTest } from "./runMigrationTest.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { createProducts } from "tests/utils/productUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { addPrefixToProducts, replaceItems } from "../utils.js";
import { runMigrationTest } from "./runMigrationTest.js";
let wordsItem = constructArrearItem({
const wordsItem = constructArrearItem({
featureId: TestFeature.Words,
});
export let pro = constructProduct({
export const pro = constructProduct({
items: [wordsItem],
type: "pro",
isDefault: false,
@@ -37,13 +36,13 @@ export let pro = constructProduct({
const testCase = "migrations2";
describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro usage product`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
const curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
@@ -80,7 +79,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro usage pro
testClockId = testClockId1!;
});
it("should attach free product", async function () {
it("should attach free product", async () => {
await attachAndExpectCorrect({
autumn,
customerId,
@@ -93,8 +92,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro usage pro
});
let newPro: ProductV2;
let increaseWordsBy = 1500;
it("should update product to new version", async function () {
const increaseWordsBy = 1500;
it("should update product to new version", async () => {
newPro = structuredClone(pro);
let newItems = replaceItems({
@@ -121,8 +120,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro usage pro
});
});
it("should attach track usage and get correct balance", async function () {
let wordsUsage = 120000;
it("should attach track usage and get correct balance", async () => {
const wordsUsage = 120000;
await autumn.track({
customer_id: customerId,
value: wordsUsage,

View File

@@ -92,6 +92,7 @@ describe(`${chalk.yellowBright("upgrade1: Testing usage upgrades")}`, () => {
waitForSeconds: 10,
});
return;
await attachAndExpectCorrect({
autumn,
customerId,
@@ -103,6 +104,7 @@ describe(`${chalk.yellowBright("upgrade1: Testing usage upgrades")}`, () => {
});
});
return;
test("should attach growth product", async () => {
const wordsUsage = 200000;
await autumn.track({

View File

@@ -148,7 +148,7 @@ export const completeInvoiceCheckout = async ({
);
if (postalInput) {
await postalInput.click();
await postalInput.type("123123");
await postalInput.type("SA39ST");
}
} catch (error) {
console.log("Could not find postal code input:", error);

View File

@@ -2,6 +2,11 @@ import type {
EntityBalance,
FullCustomerEntitlement,
} from "@models/cusProductModels/cusEntModels/cusEntModels.js";
import type { Entity } from "../../models/cusModels/entityModels/entityModels.js";
import type { FullCustomer } from "../../models/cusModels/fullCusModel.js";
import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import type { Feature } from "../../models/featureModels/featureModels.js";
import { notNullish } from "../utils.js";
export const formatCusEnt = ({
cusEnt,
@@ -11,8 +16,6 @@ export const formatCusEnt = ({
return `${cusEnt.entitlement.feature_id} (${cusEnt.entitlement.interval}) (${cusEnt.balance})`;
};
import type { FullCustomer } from "@autumn/shared";
export const updateCusEntInFullCus = ({
fullCus,
cusEntId,
@@ -44,3 +47,33 @@ export const updateCusEntInFullCus = ({
}
}
};
export const cusEntMatchesEntity = ({
cusEnt,
entity,
features,
}: {
cusEnt: FullCusEntWithFullCusProduct;
entity?: Entity;
features?: Feature[];
}) => {
if (!entity) return true;
let cusProductMatch = true;
if (notNullish(cusEnt.customer_product?.internal_entity_id)) {
cusProductMatch =
cusEnt.customer_product.internal_entity_id === entity.internal_id;
}
let entityFeatureIdMatch = true;
// let feature = features?.find(
// (f) => f.id == cusEnt.entitlement.entity_feature_id,
// );
if (notNullish(cusEnt.entitlement.entity_feature_id)) {
entityFeatureIdMatch =
cusEnt.entitlement.entity_feature_id === entity.feature_id;
}
return cusProductMatch && entityFeatureIdMatch;
};

View File

@@ -1,10 +1,11 @@
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import type { Entity } from "../../models/cusModels/entityModels/entityModels.js";
import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import type { FullCustomerPrice } from "../../models/cusProductModels/cusPriceModels/cusPriceModels.js";
import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js";
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
import type { BillingType } from "../../models/productModels/priceModels/priceEnums.js";
import type { FullProduct } from "../../models/productModels/productModels.js";
import { cusEntMatchesEntity } from "../cusEntUtils/cusEntUtils.js";
import { sortCusEntsForDeduction } from "../cusEntUtils/sortCusEntsForDeduction.js";
import { getBillingType } from "../productUtils/priceUtils.js";
@@ -51,14 +52,16 @@ export const cusProductsToCusEnts = ({
reverseOrder = false,
featureId,
featureIds,
entity,
}: {
cusProducts: FullCusProduct[];
inStatuses?: CusProductStatus[];
reverseOrder?: boolean;
featureId?: string;
featureIds?: string[];
entity?: Entity;
}) => {
let cusEnts: FullCustomerEntitlement[] = [];
let cusEnts: FullCusEntWithFullCusProduct[] = [];
for (const cusProduct of cusProducts) {
if (!inStatuses.includes(cusProduct.status)) {
@@ -85,6 +88,15 @@ export const cusProductsToCusEnts = ({
);
}
if (entity) {
cusEnts = cusEnts.filter((cusEnt) =>
cusEntMatchesEntity({
cusEnt: cusEnt,
entity,
}),
);
}
sortCusEntsForDeduction(cusEnts, reverseOrder);
return cusEnts as FullCusEntWithFullCusProduct[];