wip
This commit is contained in:
10
bun.lock
10
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
10
server/src/external/autumn/autumnCli.ts
vendored
10
server/src/external/autumn/autumnCli.ts
vendored
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
92
server/src/honoMiddlewares/rateLimitMiddleware.ts
Normal file
92
server/src/honoMiddlewares/rateLimitMiddleware.ts
Normal 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,
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user