Merge branch 'feat/attach-v2' of https://github.com/useautumn/autumn into feat/attach-v2

This commit is contained in:
John Yeo
2026-02-06 12:28:59 -08:00
170 changed files with 6158 additions and 2606 deletions

24
.github/workflows/server-typecheck.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Server Type Check
on:
pull_request:
jobs:
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
- name: Install dependencies
run: bun install
- name: Run TypeScript type check
run: cd server && bun ts

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
cd server && bun ts

View File

@@ -24,6 +24,7 @@
"@types/node": "^24.9.1",
"concurrently": "^9.2.1",
"dotenv": "^16.6.1",
"husky": "^9.1.7",
"inquirer": "^12.10.0",
},
},
@@ -2589,6 +2590,8 @@
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],

View File

@@ -52,7 +52,8 @@
"q": "lsof -ti:8080 -ti:3000 | xargs kill -9",
"knip": "knip",
"knip:fix": "knip --fix",
"knip:fix-all": "knip --fix --allow-remove-files"
"knip:fix-all": "knip --fix --allow-remove-files",
"prepare": "husky"
},
"dependencies": {
"@aws-sdk/client-firehose": "^3.975.0",
@@ -74,6 +75,7 @@
"@types/node": "^24.9.1",
"concurrently": "^9.2.1",
"dotenv": "^16.6.1",
"husky": "^9.1.7",
"inquirer": "^12.10.0"
}
}

View File

@@ -671,6 +671,29 @@ export class AutumnInt {
});
return data;
},
list: async (params: { customer_id: string; entity_id?: string }) => {
const data = await this.post(`/events/list`, params);
return data;
},
aggregate: async (params: {
customer_id: string;
entity_id?: string;
feature_id?: string;
}) => {
const data = await this.post(`/events/aggregate`, params);
return data;
},
query: async (params: {
customer_id: string;
entity_id?: string;
feature_id?: string;
}) => {
const data = await this.post(`/query`, params);
return data;
},
};
stripe = {

View File

@@ -4,6 +4,7 @@ import { createAggregateGroupablePipe } from "./pipes/aggregateGroupablePipe.js"
import { createAggregatePipe } from "./pipes/aggregatePipe.js";
import { createAggregateSimplePipe } from "./pipes/aggregateSimplePipe.js";
import { createListEventNamesPipe } from "./pipes/listEventNamesPipe.js";
import { createListEventsPaginatedPipe } from "./pipes/listEventsPaginatedPipe.js";
import { createListEventsPipe } from "./pipes/listEventsPipe.js";
const TINYBIRD_API_URL = process.env.TINYBIRD_API_URL;
@@ -47,8 +48,10 @@ export const tinybirdPipes = tinybirdClient
aggregate: createAggregatePipe(tinybirdClient),
aggregateSimple: createAggregateSimplePipe(tinybirdClient),
aggregateGroupable: createAggregateGroupablePipe(tinybirdClient),
listEvents: createListEventsPipe(tinybirdClient),
listEventNames: createListEventNamesPipe(tinybirdClient),
listEventsPaginated: createListEventsPaginatedPipe(tinybirdClient),
/** @deprecated Use listEventsPaginated instead. Kept for backwards compatibility. */
listEvents: createListEventsPipe(tinybirdClient),
}
: null;
@@ -94,6 +97,8 @@ export type {
AggregateSimplePipeRow,
ListEventNamesPipeParams,
ListEventNamesPipeRow,
ListEventsPaginatedPipeParams,
ListEventsPaginatedPipeRow,
ListEventsPipeParams,
ListEventsPipeRow,
} from "./pipes/index.js";

View File

@@ -19,13 +19,6 @@ export {
aggregateSimplePipeResponseSchema,
createAggregateSimplePipe,
} from "./aggregateSimplePipe.js";
export {
createListEventsPipe,
type ListEventsPipeParams,
type ListEventsPipeRow,
listEventsPipeParamsSchema,
listEventsPipeResponseSchema,
} from "./listEventsPipe.js";
export {
createListEventNamesPipe,
type ListEventNamesPipeParams,
@@ -33,3 +26,17 @@ export {
listEventNamesPipeParamsSchema,
listEventNamesPipeResponseSchema,
} from "./listEventNamesPipe.js";
export {
createListEventsPaginatedPipe,
type ListEventsPaginatedPipeParams,
type ListEventsPaginatedPipeRow,
listEventsPaginatedPipeParamsSchema,
listEventsPaginatedPipeResponseSchema,
} from "./listEventsPaginatedPipe.js";
export {
createListEventsPipe,
type ListEventsPipeParams,
type ListEventsPipeRow,
listEventsPipeParamsSchema,
listEventsPipeResponseSchema,
} from "./listEventsPipe.js";

View File

@@ -0,0 +1,40 @@
import type { Tinybird } from "@chronark/zod-bird";
import { z } from "zod";
/** Response schema for the list_events_paginated pipe */
export const listEventsPaginatedPipeResponseSchema = z.object({
id: z.string(),
customer_id: z.string(),
event_name: z.string(),
timestamp: z.string(),
value: z.number().nullable(),
properties: z.string().nullable(),
});
export type ListEventsPaginatedPipeRow = z.infer<
typeof listEventsPaginatedPipeResponseSchema
>;
/** Parameters schema for the list_events_paginated pipe */
export const listEventsPaginatedPipeParamsSchema = z.object({
org_id: z.string(),
env: z.string(),
start_date: z.string().optional(),
end_date: z.string().optional(),
customer_id: z.string().optional(),
event_names: z.array(z.string()).optional(),
limit: z.number().optional(),
offset: z.number().optional(),
});
export type ListEventsPaginatedPipeParams = z.infer<
typeof listEventsPaginatedPipeParamsSchema
>;
/** Creates the list_events_paginated pipe caller */
export const createListEventsPaginatedPipe = (tb: Tinybird) =>
tb.buildPipe({
pipe: "list_events_paginated",
parameters: listEventsPaginatedPipeParamsSchema,
data: listEventsPaginatedPipeResponseSchema,
});

View File

@@ -1,7 +1,10 @@
import type { Tinybird } from "@chronark/zod-bird";
import { z } from "zod";
/** Response schema for the list_events pipe */
/**
* Response schema for the legacy list_events pipe.
* Returns more fields than list_events_paginated (includes idempotency_key, entity_id, org_id, env).
*/
export const listEventsPipeResponseSchema = z.object({
id: z.string(),
org_id: z.string(),
@@ -17,22 +20,22 @@ export const listEventsPipeResponseSchema = z.object({
export type ListEventsPipeRow = z.infer<typeof listEventsPipeResponseSchema>;
/** Parameters schema for the list_events pipe */
/** Parameters schema for the legacy list_events pipe */
export const listEventsPipeParamsSchema = z.object({
org_id: z.string(),
env: z.string(),
start_date: z.string(),
end_date: z.string(),
start_date: z.string().optional(),
end_date: z.string().optional(),
customer_id: z.string().optional(),
event_name: z.string().optional(),
limit: z.number().optional(),
cursor_timestamp: z.string().optional(),
cursor_id: z.string().optional(),
limit: z.number().optional(),
});
export type ListEventsPipeParams = z.infer<typeof listEventsPipeParamsSchema>;
/** Creates the list_events pipe caller */
/** Creates the legacy list_events pipe caller */
export const createListEventsPipe = (tb: Tinybird) =>
tb.buildPipe({
pipe: "list_events",

View File

@@ -1,15 +0,0 @@
export const GENERAL_RATE_LIMIT = 1000; // per org
export const TRACK_RATE_LIMIT = 10000; // per customer ID
export const CHECK_RATE_LIMIT = 10000; // per customer ID
// const TRACK_RATE_LIMIT = 10;
// const CHECK_RATE_LIMIT = 10;
// const GENERAL_RATE_LIMIT = 10;
export enum RateLimitType {
General = "general",
Track = "track",
Check = "check",
Events = "events",
Attach = "attach",
}

View File

@@ -1,179 +0,0 @@
import type { Context } from "hono";
import {
parseCustomerIdFromBody,
parseCustomerIdFromUrl,
} from "../../honoMiddlewares/analyticsMiddleware";
import { matchRoute } from "../../honoMiddlewares/middlewareUtils";
import type { HonoEnv } from "../../honoUtils/HonoEnv";
import {
CHECK_RATE_LIMIT,
GENERAL_RATE_LIMIT,
RateLimitType,
TRACK_RATE_LIMIT,
} from "./rateLimitConstants";
export const getRateLimitType = (c: Context<HonoEnv>) => {
const method = c.req.method;
const path = c.req.path;
// Exact match patterns for track endpoints
const trackPatterns = [
{
method: "POST",
url: "/v1/events",
},
{
method: "POST",
url: "/v1/track",
},
];
// Patterns for check endpoints (including dynamic customer_id)
const checkPatterns = [
{
method: "POST",
url: "/v1/check",
},
{
method: "POST",
url: "/v1/entitled",
},
];
const getCustomerPatterns = [
{
method: "GET",
url: "/v1/customers/:customer_id",
},
{
method: "GET",
url: "/v1/customers/:customer_id/entities/:entity_id",
},
{
method: "POST",
url: "/v1/customers",
},
];
const eventsPatterns = [
{
method: "POST",
url: "/v1/events/list",
},
{
method: "POST",
url: "/v1/events/aggregate",
},
{
method: "POST",
url: "/v1/query",
},
];
const attachPatterns = [
{
method: "POST",
url: "/v1/attach",
},
];
if (
attachPatterns.some((pattern) => matchRoute({ url: path, method, pattern }))
) {
return RateLimitType.Attach;
}
if (
trackPatterns.some((pattern) => matchRoute({ url: path, method, pattern }))
) {
return RateLimitType.Track;
}
if (
checkPatterns.some((pattern) =>
matchRoute({ url: path, method, pattern }),
) ||
getCustomerPatterns.some((pattern) =>
matchRoute({ url: path, method, pattern }),
)
) {
return RateLimitType.Check;
}
if (
eventsPatterns.some((pattern) => matchRoute({ url: path, method, pattern }))
) {
return RateLimitType.Events;
}
return RateLimitType.General;
};
export const getRateLimitKey = async ({
c,
rateLimitType,
}: {
c: Context<HonoEnv>;
rateLimitType: RateLimitType;
}) => {
const ctx = c.get("ctx");
const orgId = ctx.org?.id;
const env = ctx.env;
// 1. If rate limit type is general
switch (rateLimitType) {
case RateLimitType.Track: {
const res = await parseCustomerIdFromBody(c);
const customerId = res?.customerId;
return `track:${orgId}:${env}:${customerId}`;
}
case RateLimitType.Check: {
const res = await parseCustomerIdFromBody(c);
const urlCustomerId = parseCustomerIdFromUrl({ url: c.req.path });
const customerId = res?.customerId || urlCustomerId;
return `check:${orgId}:${env}:${customerId}`;
}
case RateLimitType.Events: {
const res = await parseCustomerIdFromBody(c);
const customerId = res?.customerId;
return `events:${orgId}:${env}:${customerId}`;
}
case RateLimitType.Attach: {
const res = await parseCustomerIdFromBody(c);
const customerId = res?.customerId;
return `attach:${orgId}:${env}:${customerId}`;
}
case RateLimitType.General:
return `general:${orgId}:${env}`;
}
};
const getRateLimitConfig = ({
rateLimitType,
}: {
rateLimitType: RateLimitType;
}) => {
switch (rateLimitType) {
case RateLimitType.Track:
return {
windowMs: 1000, // 1 second window
limit: TRACK_RATE_LIMIT,
};
case RateLimitType.Check:
return {
windowMs: 1000, // 1 second window
limit: CHECK_RATE_LIMIT,
};
case RateLimitType.General:
return {
windowMs: 1000, // 1 second window
limit: GENERAL_RATE_LIMIT,
};
}
};

View File

@@ -19,6 +19,7 @@ export const idempotencyMiddleware = async (
orgId: ctx.org.id,
env: ctx.env,
idempotencyKey,
logger: ctx.logger,
});
}

View File

@@ -1,83 +1,19 @@
import type { Context, Env, Next } from "hono";
import { rateLimiter } from "hono-rate-limiter";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import {
CHECK_RATE_LIMIT,
GENERAL_RATE_LIMIT,
RateLimitType,
TRACK_RATE_LIMIT,
} from "../external/upstash/rateLimitConstants";
import {
getLimiterForType,
getRateLimitKey,
setRateLimitKeyInContext,
} from "@/internal/misc/rateLimiter/rateLimitFactory";
import {
getRateLimitType,
} from "../external/upstash/rateLimitUtils";
RateLimitType,
} from "../internal/misc/rateLimiter/rateLimitConfigs";
/**
* In-memory rate limiting middleware for Hono
* Uses different rate limits based on endpoint type (General, Track, Check)
*/
// Helper to get rate limit key from context
const getRateLimitKeyFromContext = (c: Context): string => {
return (c as Context & { rateLimitKey?: string }).rateLimitKey ?? "unknown";
};
// Helper to set rate limit key in context
const setRateLimitKeyInContext = (c: Context, key: string): void => {
(c as Context & { rateLimitKey: string }).rateLimitKey = key;
};
// Create single rate limiters that share the same in-memory store
const generalLimiter = rateLimiter({
windowMs: 1000,
limit: GENERAL_RATE_LIMIT,
standardHeaders: "draft-6",
keyGenerator: getRateLimitKeyFromContext,
});
const trackLimiter = rateLimiter({
windowMs: 1000,
limit: TRACK_RATE_LIMIT,
standardHeaders: "draft-6",
keyGenerator: getRateLimitKeyFromContext,
});
const checkLimiter = rateLimiter({
windowMs: 1000,
limit: CHECK_RATE_LIMIT,
standardHeaders: "draft-6",
keyGenerator: getRateLimitKeyFromContext,
});
const eventsLimiter = rateLimiter({
windowMs: 1000,
limit: 5,
standardHeaders: "draft-6",
keyGenerator: getRateLimitKeyFromContext,
});
const attachRateLimiter = rateLimiter({
windowMs: 60000,
limit: 5,
standardHeaders: "draft-6",
keyGenerator: getRateLimitKeyFromContext,
});
const getLimiterForType = (type: RateLimitType) => {
switch (type) {
case RateLimitType.General:
return generalLimiter;
case RateLimitType.Track:
return trackLimiter;
case RateLimitType.Check:
return checkLimiter;
case RateLimitType.Events:
return eventsLimiter;
case RateLimitType.Attach:
return attachRateLimiter;
}
};
export const rateLimitMiddleware = async (c: Context<HonoEnv>, next: Next) => {
const ctx = c.get("ctx");

View File

@@ -0,0 +1,54 @@
import type { Context, Next } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { invalidateProductsCache } from "@/internal/products/productCacheUtils.js";
import { matchRoute } from "./middlewareUtils.js";
/**
* Route patterns that trigger products cache invalidation.
* These are the simple CRUD routes - complex cases (copy across envs, conditional invalidation)
* are handled explicitly in their respective handlers.
*/
const productRoutes = [
{ method: "POST", url: "/products" },
{ method: "POST", url: "/products/:product_id" },
{ method: "PATCH", url: "/products/:product_id" },
{ method: "DELETE", url: "/products/:product_id" },
];
/**
* Hono middleware that clears products cache after successful responses
* for specific routes. Only handles simple cases where orgId/env come from ctx.
*
* Edge cases handled explicitly in handlers:
* - handleCopyProductV2: invalidates source + target envs
* - handleCopyEnvironment: invalidates live env specifically
* - handleSyncPreviewPricing: different org context (preview org)
* - handlePushOrganisationConfiguration: conditional (only if products created)
* - handleNukeOrganisationConfiguration: internal route
*/
export const refreshProductsCacheMiddleware = async (
c: Context<HonoEnv>,
next: Next,
) => {
await next();
if (c.res.status < 200 || c.res.status >= 300) return;
const ctx = c.get("ctx");
if (ctx.testOptions?.skipCacheDeletion) return;
const pathname = new URL(c.req.url).pathname.replace("/v1", "");
const method = c.req.method;
const match = productRoutes.find((pattern) =>
matchRoute({ url: pathname, method, pattern }),
);
if (!match) return;
await invalidateProductsCache({
orgId: ctx.org.id,
env: ctx.env,
});
};

View File

@@ -0,0 +1,150 @@
import type {
BillingCycleResult,
ClickHouseResult,
FullCustomer,
} from "@autumn/shared";
import {
getTinybirdPipes,
type ListEventsPipeRow,
} from "@/external/tinybird/initTinybird.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getBillingCycleStartDate } from "../analyticsUtils.js";
const DEFAULT_LIMIT = 1000;
const formatJsDateToClickHouseDateTime = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const calculateStartDateFromInterval = (interval: string): Date => {
const startDate = new Date();
switch (interval) {
case "24h":
startDate.setHours(startDate.getHours() - 24);
break;
case "7d":
startDate.setDate(startDate.getDate() - 7);
break;
case "30d":
startDate.setDate(startDate.getDate() - 30);
break;
case "90d":
startDate.setDate(startDate.getDate() - 90);
break;
default:
// Default to 30 days
startDate.setDate(startDate.getDate() - 30);
break;
}
return startDate;
};
export type LegacyListRawEventsParams = {
customer_id?: string;
interval?: string;
customer?: FullCustomer;
aggregateAll?: boolean;
event_name?: string;
limit?: number;
cursor_timestamp?: string;
cursor_id?: string;
};
/**
* @deprecated Use listRawEvents instead. This uses the legacy list_events pipe
* which returns additional fields (idempotency_key, entity_id, org_id, env).
*/
export const _legacyListRawEvents = async ({
ctx,
params,
}: {
ctx: AutumnContext;
params: LegacyListRawEventsParams;
}): Promise<ClickHouseResult<ListEventsPipeRow>> => {
const pipes = getTinybirdPipes();
const { org, env, db } = ctx;
const intervalType = params.interval ?? "30d";
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
// Calculate billing cycle dates if needed
const billingCycleResult =
isBillingCycle && !params.aggregateAll && params.customer
? ((await getBillingCycleStartDate(
params.customer,
db,
intervalType as "1bc" | "3bc",
)) as BillingCycleResult | null)
: null;
// Calculate date range
const startDate = calculateStartDateFromInterval(intervalType);
const finalStartDate =
isBillingCycle && billingCycleResult?.startDate
? billingCycleResult.startDate
: formatJsDateToClickHouseDateTime(startDate);
const finalEndDate =
isBillingCycle && billingCycleResult?.endDate
? billingCycleResult.endDate
: formatJsDateToClickHouseDateTime(new Date());
const pipeParams = {
org_id: org.id,
env,
start_date: finalStartDate,
end_date: finalEndDate,
customer_id: params.aggregateAll ? undefined : params.customer_id,
event_name: params.event_name,
cursor_timestamp: params.cursor_timestamp,
cursor_id: params.cursor_id,
limit: params.limit ?? DEFAULT_LIMIT,
};
ctx.logger.debug(
"[_legacyListRawEvents] Querying via legacy list_events pipe",
{
customerId: params.customer_id,
aggregateAll: params.aggregateAll,
startDate: finalStartDate,
endDate: finalEndDate,
limit: pipeParams.limit,
},
);
const startTime = performance.now();
const result = await pipes.listEvents(pipeParams);
const queryDuration = performance.now() - startTime;
ctx.logger.debug("[_legacyListRawEvents] Result", {
queryMs: Math.round(queryDuration),
rowCount: result.data.length,
});
return {
meta: [
{ name: "id" },
{ name: "org_id" },
{ name: "env" },
{ name: "customer_id" },
{ name: "event_name" },
{ name: "timestamp" },
{ name: "value" },
{ name: "properties" },
{ name: "idempotency_key" },
{ name: "entity_id" },
],
rows: result.data.length,
data: result.data,
};
};

View File

@@ -14,6 +14,7 @@ import {
getTinybirdPipes,
} from "@/external/tinybird/initTinybird.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { validatePropertyPathForJSON } from "@/internal/analytics/actions/eventValidationUtils.js";
import { getBillingCycleStartDate } from "../analyticsUtils.js";
const DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
@@ -356,6 +357,8 @@ export const aggregate = async ({
propertyKey = params.group_by;
}
validatePropertyPathForJSON({ propertyKey });
const pipeParams = {
org_id: org.id,
env,
@@ -375,8 +378,11 @@ export const aggregate = async ({
const result = await pipes.aggregateGroupable(pipeParams);
// Extract truncation flag from first row (all rows have the same value)
truncated = result.data.length > 0 && result.data[0]._truncated === true;
// For external API (enforceGroupLimit), truncated is always false
// For internal API, return the actual truncation status from the pipe
truncated = params.enforceGroupLimit
? false
: result.data.length > 0 && result.data[0]._truncated === true;
formatted = formatGroupableResults({
rows: result.data,

View File

@@ -1,9 +1,12 @@
import { aggregate } from "./aggregate.js";
import { _legacyListRawEvents } from "./_legacyListRawEvents.js";
import { aggregate } from "./aggregate";
import { getCountAndSum } from "./getCountAndSum.js";
import { getEventById } from "./getEventById.js";
import { getTopEventNames } from "./getTopEventNames.js";
import { listEventNames } from "./listEventNames.js";
import { listEvents } from "./listEvents.js";
import { listRawEvents } from "./listRawEvents.js";
import { _legacyListRawEvents } from "./_legacyListRawEvents.js";
export const eventActions = {
aggregate,
@@ -11,5 +14,8 @@ export const eventActions = {
getEventById,
getTopEventNames,
listEventNames,
listEvents,
listRawEvents,
/** @deprecated Use listRawEvents instead. Returns additional fields (idempotency_key, entity_id). */
_legacyListRawEvents,
} as const;

View File

@@ -0,0 +1,21 @@
import { ErrCode, RecaseError } from "@shared/index";
import { StatusCodes } from "http-status-codes";
export const validatePropertyPathForJSON = ({
propertyKey,
}: {
propertyKey: string;
}) => {
// Validate property path segments (matches old ClickHouse behavior)
const pathSegments = propertyKey.split(".");
for (const segment of pathSegments) {
if (!/^[a-zA-Z0-9_]+$/.test(segment)) {
throw new RecaseError({
message:
"Invalid property path. Should only contain alphanumeric and underscore characters.",
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
}
}
};

View File

@@ -0,0 +1,93 @@
import type { ApiEventsListItem } from "@autumn/shared";
import { epochToDateTime } from "@autumn/shared/api/common/epochUtils";
import { getTinybirdPipes } from "@/external/tinybird/initTinybird.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
/** Lists events for the external API with offset-based pagination */
export const listEvents = async ({
ctx,
params,
}: {
ctx: AutumnContext;
params: {
customer_id?: string;
feature_ids?: string[];
custom_range?: { start?: number; end?: number };
offset: number;
limit: number;
};
}) => {
const pipes = getTinybirdPipes();
const { org, env } = ctx;
// Convert epoch ms to DateTime strings (if provided)
const startDate = params.custom_range?.start
? epochToDateTime(params.custom_range.start)
: undefined;
const endDate = params.custom_range?.end
? epochToDateTime(params.custom_range.end)
: undefined;
// Fetch N+1 for has_more calculation
const fetchLimit = params.limit + 1;
ctx.logger.debug("Listing events for API via Tinybird", {
customerId: params.customer_id,
featureIds: params.feature_ids,
startDate,
endDate,
offset: params.offset,
limit: params.limit,
});
const startTime = performance.now();
const result = await pipes.listEventsPaginated({
org_id: org.id,
env,
start_date: startDate,
end_date: endDate,
customer_id: params.customer_id,
event_names: params.feature_ids,
limit: fetchLimit,
offset: params.offset,
});
const queryDuration = performance.now() - startTime;
const hasMore = result.data.length > params.limit;
const rows = hasMore ? result.data.slice(0, params.limit) : result.data;
// Transform to API format
const list: ApiEventsListItem[] = rows.map((row) => {
let properties = {};
if (row.properties) {
try {
properties = JSON.parse(row.properties);
} catch {
// Invalid JSON, use empty object
}
}
return {
id: row.id,
timestamp: new Date(row.timestamp).getTime(),
feature_id: row.event_name,
customer_id: row.customer_id,
value: row.value ?? 0,
properties,
};
});
ctx.logger.debug("Events list result", {
queryMs: Math.round(queryDuration),
rowCount: list.length,
hasMore,
});
return {
list,
has_more: hasMore,
total: list.length,
offset: params.offset,
limit: params.limit,
};
};

View File

@@ -6,7 +6,7 @@ import type {
} from "@autumn/shared";
import {
getTinybirdPipes,
type ListEventsPipeRow,
type ListEventsPaginatedPipeRow,
} from "@/external/tinybird/initTinybird.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getBillingCycleStartDate } from "../analyticsUtils.js";
@@ -50,7 +50,7 @@ const calculateStartDateFromInterval = (interval: string): Date => {
/** Converts pipe row to the expected ClickHouse format */
const convertPipeRowToClickHouseFormat = (
row: ListEventsPipeRow,
row: ListEventsPaginatedPipeRow,
): RawEventFromClickHouse => ({
id: row.id,
customer_id: row.customer_id,
@@ -67,8 +67,6 @@ export type ListRawEventsParams = {
aggregateAll?: boolean;
event_name?: string;
limit?: number;
cursor_timestamp?: string;
cursor_id?: string;
};
/** Lists raw events with optional filtering by customer and date range */
@@ -114,10 +112,9 @@ export const listRawEvents = async ({
start_date: finalStartDate,
end_date: finalEndDate,
customer_id: params.aggregateAll ? undefined : params.customer_id,
event_name: params.event_name,
event_names: params.event_name ? [params.event_name] : undefined,
limit: params.limit ?? DEFAULT_LIMIT,
cursor_timestamp: params.cursor_timestamp,
cursor_id: params.cursor_id,
offset: 0,
};
ctx.logger.debug("Listing raw events via Tinybird pipe", {
@@ -126,11 +123,10 @@ export const listRawEvents = async ({
startDate: finalStartDate,
endDate: finalEndDate,
limit: pipeParams.limit,
hasCursor: !!(params.cursor_timestamp && params.cursor_id),
});
const startTime = performance.now();
const result = await pipes.listEvents(pipeParams);
const result = await pipes.listEventsPaginated(pipeParams);
const queryDuration = performance.now() - startTime;
ctx.logger.debug("Raw events result", {

View File

@@ -1,13 +1,13 @@
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleGetEventNames } from "./internalHandlers/handleGetEventNames.js";
import { handleInternalAggregateEvents } from "./internalHandlers/handleInternalAggregateEvents.js";
import { handleInternalListRawEvents } from "./internalHandlers/handleInternalListRawEvents.js";
import { handleListEventNames } from "./internalHandlers/handleListEventNames.js";
import { handleQueryEvents } from "./internalHandlers/handleQueryEvents.js";
import { handleQueryRawEvents } from "./internalHandlers/handleQueryRawEvents.js";
export const internalAnalyticsRouter = new Hono<HonoEnv>();
internalAnalyticsRouter.get("/event_names", ...handleGetEventNames);
internalAnalyticsRouter.get("/event_names/list", ...handleListEventNames);
internalAnalyticsRouter.post("/events", ...handleQueryEvents);
internalAnalyticsRouter.post("/raw", ...handleQueryRawEvents);
internalAnalyticsRouter.post("/events", ...handleInternalAggregateEvents);
internalAnalyticsRouter.post("/raw", ...handleInternalListRawEvents);

View File

@@ -1,7 +1,7 @@
import { type Feature, FeatureType } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { eventActions } from "../actions/index.js";
import { AnalyticsService } from "../AnalyticsService.js";
import { eventActions } from "../actions/eventActions.js";
/**
* Get top event names for the organization
@@ -9,7 +9,7 @@ import { AnalyticsService } from "../AnalyticsService.js";
export const handleGetEventNames = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const { org, env, features } = ctx;
const { features } = ctx;
AnalyticsService.handleEarlyExit();

View File

@@ -10,9 +10,9 @@ import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CusService } from "@/internal/customers/CusService.js";
import { AnalyticsService } from "../AnalyticsService.js";
import { eventActions } from "../actions/index.js";
import { eventActions } from "../actions/eventActions.js";
const QueryEventsSchema = z.object({
const InternalAggregateEventsSchema = z.object({
interval: z.string().nullish(),
event_names: z.array(z.string()),
customer_id: z.string().optional(),
@@ -24,8 +24,8 @@ const QueryEventsSchema = z.object({
/**
* Query events by customer ID
*/
export const handleQueryEvents = createRoute({
body: QueryEventsSchema,
export const handleInternalAggregateEvents = createRoute({
body: InternalAggregateEventsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env, features } = ctx;

View File

@@ -3,10 +3,9 @@ import { StatusCodes } from "http-status-codes";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CusService } from "@/internal/customers/CusService.js";
import { AnalyticsService } from "../AnalyticsService.js";
import { eventActions } from "../actions/index.js";
import { eventActions } from "../actions/eventActions.js";
const QueryRawEventsSchema = z.object({
const InternalListRawEventsSchema = z.object({
interval: z.string().nullish(),
customer_id: z.string().nullish(),
});
@@ -14,15 +13,13 @@ const QueryRawEventsSchema = z.object({
/**
* Query raw events by customer ID
*/
export const handleQueryRawEvents = createRoute({
body: QueryRawEventsSchema,
export const handleInternalListRawEvents = createRoute({
body: InternalListRawEventsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const { interval, customer_id } = c.req.valid("json");
AnalyticsService.handleEarlyExit();
let aggregateAll = false;
let customer: FullCustomer | undefined;

View File

@@ -1,7 +1,7 @@
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { AnalyticsService } from "../AnalyticsService.js";
import { eventActions } from "../actions/index.js";
import { eventActions } from "../actions/eventActions.js";
const ListEventNamesSchema = z.object({
limit: z.coerce.number().optional(),

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono";
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleAggregateEvents } from "../events/handlers/handleAggregateEvents.js";
import { handleExternalAggregateEvents } from "../events/handlers/handleExternalAggregateEvents.js";
export const legacyAnalyticsRouter = new Hono<HonoEnv>();
legacyAnalyticsRouter.post("", ...handleAggregateEvents);
legacyAnalyticsRouter.post("", ...handleExternalAggregateEvents);

View File

@@ -13,6 +13,7 @@ export const handleEventIdempotencyKey = async ({
orgId: ctx.org.id,
env: ctx.env,
idempotencyKey: `track:${body.idempotency_key}`,
logger: ctx.logger,
});
// const eventInfo = buildEventInfo(body);

View File

@@ -0,0 +1,170 @@
import {
type CheckoutChange,
CusExpand,
cusProductToProduct,
type FullCusProduct,
type FullProduct,
isPrepaidPrice,
orgToCurrency,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { cusProductToBalances } from "@/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.js";
import { getPlanResponse } from "@/internal/products/productUtils/productResponseUtils/getPlanResponse.js";
import type { AttachParams } from "../../customers/cusProducts/AttachParams.js";
/**
* Convert cusProduct.options to feature_quantities with actual quantities
* (multiplied by billingUnits for prepaid features)
*/
function cusProductToFeatureQuantities({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) {
return cusProduct.options.map((option) => {
const cusPrice = cusProduct.customer_prices.find((cp) => {
const cusEnt = cusProduct.customer_entitlements.find(
(ce) =>
ce.internal_feature_id === option.internal_feature_id ||
ce.entitlement.feature_id === option.feature_id,
);
return (
cusEnt &&
cp.price.config.internal_feature_id ===
cusEnt.entitlement.internal_feature_id
);
});
let quantity = option.quantity;
if (cusPrice && isPrepaidPrice(cusPrice.price)) {
const billingUnits = cusPrice.price.config.billing_units ?? 1;
quantity = option.quantity * billingUnits;
}
return {
feature_id: option.feature_id,
quantity,
};
});
}
/**
* Build incoming change from the new product being attached
*/
async function buildIncomingChange({
ctx,
attachParams,
newProduct,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
newProduct: FullProduct;
}): Promise<CheckoutChange> {
const currency = orgToCurrency({ org: ctx.org });
const plan = await getPlanResponse({
product: newProduct,
features: ctx.features,
fullCus: attachParams.customer,
currency,
expand: [CusExpand.PlanFeaturesFeature],
});
// Build feature quantities from attach options
const featureQuantities = attachParams.optionsList.map((option) => ({
feature_id: option.feature_id,
quantity: option.quantity,
}));
return {
plan,
feature_quantities: featureQuantities,
balances: {},
};
}
/**
* Build outgoing change from the current product being replaced
*/
async function buildOutgoingChange({
ctx,
attachParams,
curCusProduct,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
curCusProduct: FullCusProduct;
}): Promise<CheckoutChange> {
const currency = orgToCurrency({ org: ctx.org });
const fullProduct = cusProductToProduct({ cusProduct: curCusProduct });
const plan = await getPlanResponse({
product: fullProduct,
features: ctx.features,
fullCus: attachParams.customer,
currency,
expand: [CusExpand.PlanFeaturesFeature],
});
const balances = cusProductToBalances({
ctx,
cusProduct: curCusProduct,
fullCustomer: attachParams.customer,
});
const featureQuantities = cusProductToFeatureQuantities({
cusProduct: curCusProduct,
});
return {
plan,
feature_quantities: featureQuantities,
balances,
};
}
/**
* Convert attach params to incoming and outgoing CheckoutChange arrays.
* Incoming = product being attached, Outgoing = product being replaced (if any).
*/
export const attachParamsToChanges = async ({
ctx,
attachParams,
curCusProduct,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
curCusProduct?: FullCusProduct;
}): Promise<{ incoming: CheckoutChange[]; outgoing: CheckoutChange[] }> => {
const incoming: CheckoutChange[] = [];
const outgoing: CheckoutChange[] = [];
// Build new product from attach params
const newProduct: FullProduct = {
...attachParams.products[0],
prices: attachParams.prices,
entitlements: attachParams.entitlements,
free_trial: attachParams.freeTrial,
};
// Always add incoming (the new product being attached)
const incomingChange = await buildIncomingChange({
ctx,
attachParams,
newProduct,
});
incoming.push(incomingChange);
// Add outgoing if there's a current product being replaced
if (curCusProduct) {
const outgoingChange = await buildOutgoingChange({
ctx,
attachParams,
curCusProduct,
});
outgoing.push(outgoingChange);
}
return { incoming, outgoing };
};

View File

@@ -15,6 +15,7 @@ import { getNewProductPreview } from "@/internal/customers/attach/handleAttachPr
import { getUpgradeProductPreview } from "@/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv";
import { attachParamsToChanges } from "./attachParamsToChanges.js";
export const attachParamsToPreview = async ({
ctx,
@@ -102,6 +103,13 @@ export const attachParamsToPreview = async ({
const { curScheduledProduct } = attachParamToCusProducts({ attachParams });
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
// Compute incoming/outgoing changes for the UI
const { incoming, outgoing } = await attachParamsToChanges({
ctx,
attachParams,
curCusProduct,
});
return {
branch,
func,
@@ -112,5 +120,7 @@ export const attachParamsToPreview = async ({
})
: null,
scheduled_product: curScheduledProduct,
incoming,
outgoing,
};
};

View File

@@ -1,5 +1,6 @@
import { AttachParamsV0Schema } from "@autumn/shared";
import { billingActions } from "@/internal/billing/v2/actions";
import { billingPlanToChanges } from "@/internal/billing/v2/utils/billingPlanToChanges.js";
import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
@@ -40,6 +41,20 @@ export const handlePreviewAttach = createRoute({
billingPlan,
});
return c.json(previewResponse, 200);
// 8. Build incoming/outgoing changes
const { incoming, outgoing } = await billingPlanToChanges({
ctx,
billingContext,
billingPlan,
});
return c.json(
{
...previewResponse,
incoming,
outgoing,
},
200,
);
},
});

View File

@@ -1,35 +1,49 @@
import { CustomerNotFoundError } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { EventService } from "@/internal/api/events/EventService";
import { CusService } from "../CusService";
import { eventActions } from "@/internal/analytics/actions/eventActions.js";
import { getCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer";
const QuerySchema = z.object({
interval: z.enum(["7d", "30d", "90d"]).optional(),
limit: z.coerce.number().min(1).max(500).optional(),
});
/**
* GET /customers/:customer_id/events
* Used by: vite/src/views/customers/customer/hooks/useCusEventsQuery.tsx
*
* Returns raw events from Tinybird using legacy pipe (includes idempotency_key, entity_id)
*/
export const handleGetCustomerEvents = createRoute({
query: QuerySchema,
handler: async (c) => {
const { db, org, env } = c.get("ctx");
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const { customer_id } = c.req.param();
const { interval, limit } = c.req.valid("query");
const customer = await CusService.get({
db,
const customer = await getCachedFullCustomer({
orgId: org.id,
env,
idOrInternalId: customer_id,
customerId: customer_id,
});
if (!customer) {
throw new CustomerNotFoundError({ customerId: customer_id });
}
const events = await EventService.getByCustomerId({
db,
internalCustomerId: customer.internal_id,
env,
orgId: org.id,
// Use legacy Tinybird pipe (includes idempotency_key, entity_id fields)
const result = await eventActions._legacyListRawEvents({
ctx,
params: {
customer_id: customer.id ?? "",
customer,
interval: interval ?? "30d",
limit: limit ?? 50,
},
});
return c.json({ events });
return c.json({ events: result.data });
},
});

View File

@@ -1,9 +1,9 @@
import { Hono } from "hono";
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleAggregateEvents } from "./handlers/handleAggregateEvents.js";
import { handleListEvents } from "./handlers/handleListEvents.js";
import { handleExternalAggregateEvents } from "./handlers/handleExternalAggregateEvents.js";
import { handleExternalListEvents } from "./handlers/handleExternalListEvents.js";
export const eventsRouter = new Hono<HonoEnv>();
eventsRouter.post("aggregate", ...handleAggregateEvents);
eventsRouter.post("list", ...handleListEvents);
eventsRouter.post("aggregate", ...handleExternalAggregateEvents);
eventsRouter.post("list", ...handleExternalListEvents);

View File

@@ -6,9 +6,9 @@ import {
RecaseError,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { eventActions } from "@/internal/analytics/actions/eventActions.js";
import { CusService } from "@/internal/customers/CusService";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { EventsAggregationService } from "../EventsAggregationService";
import {
backfillMissingGroupValues,
buildGroupedTimeseries,
@@ -16,7 +16,7 @@ import {
convertPeriodsToEpoch,
} from "../eventUtils.js";
export const handleAggregateEvents = createRoute({
export const handleExternalAggregateEvents = createRoute({
body: EventsAggregateParamsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
@@ -47,8 +47,8 @@ export const handleAggregateEvents = createRoute({
const featureIds = Array.isArray(feature_id) ? feature_id : [feature_id];
const [events, total] = await Promise.all([
EventsAggregationService.getTimeseriesEvents({
const [eventsResult, total] = await Promise.all([
eventActions.aggregate({
ctx,
params: {
aggregateAll: false,
@@ -60,9 +60,10 @@ export const handleAggregateEvents = createRoute({
group_by,
bin_size: bin_size ?? "day",
custom_range,
enforceGroupLimit: true,
},
}),
EventsAggregationService.getTotalEvents({
eventActions.getCountAndSum({
ctx,
params: {
aggregateAll: false,
@@ -76,6 +77,8 @@ export const handleAggregateEvents = createRoute({
}),
]);
const events = eventsResult.formatted;
if (!events) {
throw new RecaseError({
message: "No events found",

View File

@@ -0,0 +1,33 @@
import type { ApiEventsListResponse } from "@autumn/shared";
import { ApiEventsListParamsSchema } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { eventActions } from "@/internal/analytics/actions/eventActions.js";
export const handleExternalListEvents = createRoute({
body: ApiEventsListParamsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
const validatedParams = ApiEventsListParamsSchema.parse(
c.req.valid("json"),
);
const featureIds = validatedParams.feature_id
? Array.isArray(validatedParams.feature_id)
? validatedParams.feature_id
: [validatedParams.feature_id]
: undefined;
const result = await eventActions.listEvents({
ctx,
params: {
customer_id: validatedParams.customer_id,
feature_ids: featureIds,
custom_range: validatedParams.custom_range,
offset: validatedParams.offset,
limit: validatedParams.limit,
},
});
return c.json<ApiEventsListResponse>(result);
},
});

View File

@@ -1,19 +0,0 @@
import type { ApiEventsListResponse } from "@autumn/shared";
import { ApiEventsListParamsSchema } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { EventListService } from "../EventListService";
export const handleListEvents = createRoute({
body: ApiEventsListParamsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
const bodyParams = c.req.valid("json");
const result = await EventListService.getEvents({
ctx,
params: bodyParams,
});
return c.json<ApiEventsListResponse>(result);
},
});

View File

@@ -3,6 +3,7 @@ import { createRoute } from "@/honoMiddlewares/routeHandler";
import { CusService } from "@/internal/customers/CusService";
import { FeatureService } from "@/internal/features/FeatureService";
import { ProductService } from "@/internal/products/ProductService";
import { invalidateProductsCache } from "@/internal/products/productCacheUtils";
export const handleNukeOrganisationConfiguration = createRoute({
handler: async (c) => {
@@ -30,6 +31,8 @@ export const handleNukeOrganisationConfiguration = createRoute({
env: AppEnv.Sandbox,
});
await invalidateProductsCache({ orgId: org.id, env: AppEnv.Sandbox });
return c.json({ message: "Organisation configuration cleared" });
},
});

View File

@@ -12,6 +12,7 @@ import { FeatureService } from "@/internal/features/FeatureService";
import { createFeature } from "@/internal/features/featureActions/createFeature";
import { createProduct } from "@/internal/products/handlers/productActions/createProduct";
import { ProductService } from "@/internal/products/ProductService";
import { invalidateProductsCache } from "@/internal/products/productCacheUtils";
const OrganisationConfigurationSchema = z.object({
features: z.array(CreateFeatureV0ParamsSchema).optional().default([]),
@@ -35,6 +36,8 @@ export const handlePushOrganisationConfiguration = createRoute({
env,
});
let productsCreated = false;
await db.transaction(async (tx) => {
const txDb = tx as unknown as DrizzleCli;
const txCtx = { ...ctx, db: txDb };
@@ -86,9 +89,14 @@ export const handlePushOrganisationConfiguration = createRoute({
free_trial: apiProduct.free_trial,
},
});
productsCreated = true;
}
});
if (productsCreated) {
await invalidateProductsCache({ orgId: org.id, env });
}
return c.json({
features: body.features,
products: body.products,

View File

@@ -1,7 +1,14 @@
import { ErrCode, RecaseError } from "@autumn/shared";
import { ErrCode, ms, RecaseError } from "@autumn/shared";
import type { Logger } from "@/external/logtail/logtailUtils";
import { redis } from "@/external/redis/initRedis.js";
const IDEMPOTENCY_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
const IDEMPOTENCY_TTL_MS = ms.hours(24);
const hashIdempotencyKey = (key: string): string => {
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(key);
return hasher.digest("base64url");
};
/**
* Checks and sets an idempotency key in Redis using atomic SET NX operation.
@@ -12,20 +19,27 @@ export const checkIdempotencyKey = async ({
orgId,
env,
idempotencyKey,
logger,
}: {
orgId: string;
env: string;
idempotencyKey: string;
logger: Logger;
}): Promise<void> => {
// Fail-open: if Redis is not ready, allow the request
if (redis.status !== "ready") {
return;
}
const redisKey = `${orgId}:${env}:idempotency:${idempotencyKey}`;
const hashedKey = hashIdempotencyKey(idempotencyKey);
const redisKey = `${orgId}:${env}:idempotency:${hashedKey}`;
try {
// Use SET NX (set if not exists) for atomic check-and-set to prevent race conditions
logger.info(
`[checkIdempotencyKey] setting idempotency key ${idempotencyKey}, hash: ${hashedKey}`,
);
const wasSet = await redis.set(
redisKey,
"1",

View File

@@ -15,6 +15,7 @@ import { createFeature } from "@/internal/features/featureActions/createFeature.
import { OrgService } from "@/internal/orgs/OrgService.js";
import { createProduct } from "@/internal/products/handlers/productActions/createProduct.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { invalidateProductsCache } from "@/internal/products/productCacheUtils.js";
import { buildPreviewOrgSlug } from "./handleSetupPreviewOrg.js";
const SyncPreviewPricingSchema = z.object({
@@ -99,23 +100,34 @@ export const handleSyncPreviewPricing = createRoute({
features: [] as Awaited<ReturnType<typeof FeatureService.list>>,
};
// Create features
await Promise.all(
body.features.map((apiFeature) => {
const dbFeature = apiFeatureToDbFeature({ apiFeature });
return createFeature({
ctx: previewCtx,
data: {
id: dbFeature.id,
name: dbFeature.name,
type: dbFeature.type,
config: dbFeature.config,
event_names: dbFeature.event_names,
},
skipGenerateDisplay: true,
});
}),
);
// Deduplicate features by ID (keep first occurrence)
const seenFeatureIds = new Set<string>();
const uniqueFeatures = body.features.filter((f) => {
if (seenFeatureIds.has(f.id)) {
ctx.logger.warn(
`[Preview Sync] Duplicate feature ID found: ${f.id}, skipping...`,
);
return false;
}
seenFeatureIds.add(f.id);
return true;
});
// Create features sequentially to avoid race conditions
for (const apiFeature of uniqueFeatures) {
const dbFeature = apiFeatureToDbFeature({ apiFeature });
await createFeature({
ctx: previewCtx,
data: {
id: dbFeature.id,
name: dbFeature.name,
type: dbFeature.type,
config: dbFeature.config,
event_names: dbFeature.event_names,
},
skipGenerateDisplay: true,
});
}
// Get updated features for product creation
const updatedFeatures = await FeatureService.list({
@@ -145,6 +157,11 @@ export const handleSyncPreviewPricing = createRoute({
),
);
await invalidateProductsCache({
orgId: previewOrg.id,
env: AppEnv.Sandbox,
});
ctx.logger.debug(
`[Preview Sync] Summary: ${body.features.length} features, ${body.products.length} products`,
);

View File

@@ -1,13 +1,13 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { InternalError } from "@autumn/shared";
import { type AgentPricingConfig, InternalError } from "@autumn/shared";
import { withTracing } from "@posthog/ai";
import { convertToModelMessages, streamText, type UIMessage } from "ai";
import { Hono } from "hono";
import { PostHog } from "posthog-node";
import { z } from "zod/v4";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleSetupPreviewOrg } from "./handlers/handleSetupPreviewOrg.js";
import { handleSyncPreviewPricing } from "./handlers/handleSyncPreviewPricing.js";
import { OrganisationConfigurationSchema } from "./pricingAgentSchemas.js";
// PostHog client singleton
let phClient: PostHog | null = null;
@@ -23,226 +23,6 @@ const getPostHogClient = (): PostHog | null => {
return phClient;
};
// ============ SCHEMAS ============
const ApiFeatureType = z.enum([
"static",
"boolean",
"single_use",
"continuous_use",
"credit_system",
]);
const ProductItemInterval = z.enum([
"minute",
"hour",
"day",
"week",
"month",
"quarter",
"semi_annual",
"year",
]);
const UsageModel = z.enum(["prepaid", "pay_per_use"]);
const FreeTrialDuration = z.enum(["day", "month", "year"]);
const FeatureSchema = z
.object({
id: z
.string()
.describe(
"Unique ID for the feature (lowercase, underscores, no spaces)",
),
name: z.string().describe("Display name for the feature"),
type: ApiFeatureType.describe(
"Type: single_use for consumables, continuous_use for allocated resources, boolean for on/off",
),
display: z
.object({
singular: z
.string()
.describe(
"Singular form of the unit (e.g., 'message', 'credit', 'seat', 'API call')",
),
plural: z
.string()
.describe(
"Plural form of the unit (e.g., 'messages', 'credits', 'seats', 'API calls')",
),
})
.describe(
"REQUIRED for metered features (single_use, continuous_use, credit_system). Used for display like '100 messages' or '1 seat'.",
),
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
)
.nullish(),
})
.refine(
(data) => {
if (data.type === "credit_system") {
return data.credit_schema && data.credit_schema.length > 0;
}
return true;
},
{
message:
"Credit system features require at least one metered feature in credit_schema.",
path: ["credit_schema"],
},
);
const PriceTierSchema = z.object({
to: z
.number()
.or(z.literal("inf"))
.describe("The upper limit of this tier (use 'inf' for unlimited)"),
amount: z.number().describe("The price per unit for this tier"),
});
const ProductItemSchema = z.object({
feature_id: z
.string()
.nullish()
.describe(
"Feature ID this item relates to. Set to null for standalone flat-fee price items (e.g., subscription base price, one-time purchase price).",
),
included_usage: z
.number()
.or(z.literal("inf"))
.nullish()
.describe(
"Usage granted to the customer. Use WITHOUT price for free allocations. Use WITH usage_model and price for metered pricing.",
),
interval: ProductItemInterval.nullish().describe("Reset/billing interval"),
price: z
.number()
.nullish()
.describe(
"Price amount. When feature_id is null, this is a standalone flat fee. When feature_id is set with usage_model, this is the per-unit price.",
),
tiers: z
.array(PriceTierSchema)
.nullish()
.describe(
"Tiered pricing structure. Use instead of price for volume-based pricing. Each tier defines upper limit (to) and price per unit (amount).",
),
usage_model: UsageModel.nullish().describe(
"prepaid or pay_per_use. Required when pricing per unit of usage.",
),
billing_units: z
.number()
.nullish()
.describe("Units per price (e.g., $1 per 30 credits)"),
});
const FreeTrialSchema = z
.object({
length: z.number().describe("Length of free trial"),
duration: FreeTrialDuration.describe("Unit: day, month, or year"),
unique_fingerprint: z.boolean().default(false),
card_required: z.boolean().default(true),
})
.nullish();
const ProductSchema = z
.object({
id: z.string().describe("Unique ID (lowercase, hyphens allowed)"),
name: z.string().describe("Display name"),
is_add_on: z
.boolean()
.default(false)
.describe(
"Set to true if this product is an add-on or top-up, (can be purchased together with other base plans).",
),
is_default: z
.boolean()
.default(false)
.describe(
"Set to true ONLY if the items array is completely empty OR contains only items with price: null. ANY pricing items (including pay-per-use, overage charges, prepaid etc.) disqualifies a plan from being default.",
),
group: z
.string()
.default("")
.describe(
"A group to assign this plan to. Leave empty unless user is building pricing where a customer could subscribe to 2 or more types of plans at the same time.`",
),
items: z.array(ProductItemSchema).default([]),
free_trial: FreeTrialSchema,
})
.refine(
(data) => {
if (data.is_default) {
return data.items.every((item) => item.price == null);
}
return true;
},
{
message:
"Default plans cannot have priced items. All items must have price: null or undefined.",
path: ["is_default"],
},
)
.refine(
(data) => {
const usageBasedFeatureIds = new Set(
data.items
.filter((item) => item.feature_id != null && item.usage_model != null)
.map((item) => item.feature_id),
);
// Check if any other items reference the same feature_id
return !data.items.some(
(item) =>
item.feature_id != null &&
item.usage_model == null &&
usageBasedFeatureIds.has(item.feature_id),
);
},
{
message:
"Cannot have separate items for the same feature when one has usage-based pricing. Combine into a single item (e.g., 100 free, then $0.10 per additional).",
path: ["items"],
},
)
.refine(
(data) => {
return !data.items.some(
(item) => item.usage_model === "pay_per_use" && item.interval == null,
);
},
{
message:
"Pay-per-use pricing requires an interval. Set interval (e.g., 'month') for usage-based items.",
path: ["items"],
},
)
.refine(
(data) => {
return !data.items.some(
(item) =>
item.price != null &&
item.feature_id != null &&
item.usage_model == null,
);
},
{
message:
"Priced metered features require a usage_model. Set to 'pay_per_use' or 'prepaid'.",
path: ["items"],
},
);
const OrganisationConfigurationSchema = z.object({
features: z.array(FeatureSchema).default([]),
products: z.array(ProductSchema),
});
type PricingConfig = z.infer<typeof OrganisationConfigurationSchema>;
// ============ SYSTEM PROMPT ============
const SYSTEM_PROMPT = `You are a helpful pricing configuration assistant for Autumn, a billing and entitlements platform.
@@ -277,9 +57,10 @@ Products contain an array of items. There are THREE distinct item patterns:
\`{ feature_id: "credits", included_usage: 10000, price: 0.01, usage_model: "pay_per_use", interval: "month" }\`
→ Customer can use 10,000 credits per month, and then pays $0.01 per credit used after that.
4. **Prepaid Credit Purchase** (one-time purchase of usage):
\`{ feature_id: "credits", price: 10, usage_model: "prepaid", billing_units: 10000 }\`
→ Customer pays $10 once to receive 10,000 credits
4. **Prepaid Credit Purchase** (one-time or recurring):
\`{ feature_id: "credits", price: 10, usage_model: "prepaid", billing_units: 10000 } \`,
→ Customer pays $10 for 10,000 credits. Add \`interval: "month" \` to make it a recurring subscription with selectable quantity.
5. **Tiered Pricing**:
\`{ feature_id: "api_calls", included_usage: 1000, tiers: [{ to: 5000, amount: 0.02 }, { to: "inf", amount: 0.01 }], usage_model: "pay_per_use", interval: "month" }\`
@@ -330,8 +111,15 @@ This creates: $Y/month base price that includes 1 unit, then $Y per additional u
export const pricingAgentRouter = new Hono<HonoEnv>();
pricingAgentRouter.post("/chat", async (c) => {
const { messages, sessionId }: { messages: UIMessage[]; sessionId?: string } =
await c.req.json();
const {
messages,
sessionId,
initialConfig,
}: {
messages: UIMessage[];
sessionId?: string;
initialConfig?: AgentPricingConfig | null;
} = await c.req.json();
const ctx = c.var.ctx;
if (!process.env.ANTHROPIC_API_KEY) {
@@ -341,6 +129,25 @@ pricingAgentRouter.post("/chat", async (c) => {
});
}
// Build system prompt, optionally including initial config context
let systemPrompt = SYSTEM_PROMPT;
if (
initialConfig &&
(initialConfig.products.length > 0 || initialConfig.features.length > 0)
) {
systemPrompt += `
## Current Pricing Configuration
The user has an existing pricing setup that they want to modify. Here is their current configuration:
\`\`\`json
${JSON.stringify(initialConfig, null, 2)}
\`\`\`
When the user asks to make changes, modify this existing configuration rather than starting from scratch. Build upon what they already have unless they explicitly ask to start fresh.`;
}
// Create Anthropic client and optionally wrap with PostHog tracing
const anthropicClient = createAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
@@ -365,7 +172,7 @@ pricingAgentRouter.post("/chat", async (c) => {
const result = streamText({
model,
system: SYSTEM_PROMPT,
system: systemPrompt,
messages: await convertToModelMessages(messages),
tools: {
build_pricing: {

View File

@@ -0,0 +1,262 @@
import { z } from "zod/v4";
// ============ VALIDATION HELPERS ============
const validateOneDefaultPerGroup = ({
products,
}: {
products: {
is_default: boolean;
group: string;
free_trial?: { card_required: boolean } | null;
}[];
}): boolean => {
const productsByGroup = new Map<string, typeof products>();
for (const product of products) {
const group = product.group || "";
if (!productsByGroup.has(group)) {
productsByGroup.set(group, []);
}
productsByGroup.get(group)!.push(product);
}
for (const [_, groupProducts] of productsByGroup) {
// Count products with is_default that DON'T have card-not-required free trials
const defaultWithoutCardlessTrialCount = groupProducts.filter(
(p) =>
p.is_default && !(p.free_trial && p.free_trial.card_required === false),
).length;
if (defaultWithoutCardlessTrialCount > 1) {
return false;
}
}
return true;
};
// ============ SCHEMAS ============
const ApiFeatureType = z.enum([
"static",
"boolean",
"single_use",
"continuous_use",
"credit_system",
]);
const ProductItemInterval = z.enum([
"minute",
"hour",
"day",
"week",
"month",
"quarter",
"semi_annual",
"year",
]);
const UsageModel = z.enum(["prepaid", "pay_per_use"]);
const FreeTrialDuration = z.enum(["day", "month", "year"]);
const FeatureSchema = z
.object({
id: z
.string()
.describe(
"Unique ID for the feature (lowercase, underscores, no spaces)",
),
name: z.string().describe("Display name for the feature"),
type: ApiFeatureType.describe(
"Type: single_use for consumables, continuous_use for allocated resources, boolean for on/off",
),
display: z
.object({
singular: z
.string()
.describe(
"Singular form of the unit (e.g., 'message', 'credit', 'seat', 'API call')",
),
plural: z
.string()
.describe(
"Plural form of the unit (e.g., 'messages', 'credits', 'seats', 'API calls')",
),
})
.describe(
"REQUIRED for metered features (single_use, continuous_use, credit_system). Used for display like '100 messages' or '1 seat'.",
),
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
)
.nullish(),
})
.refine(
(data) => {
if (data.type === "credit_system") {
return data.credit_schema && data.credit_schema.length > 0;
}
return true;
},
{
message:
"Credit system features require at least one metered feature in credit_schema.",
path: ["credit_schema"],
},
);
const PriceTierSchema = z.object({
to: z
.number()
.or(z.literal("inf"))
.describe("The upper limit of this tier (use 'inf' for unlimited)"),
amount: z.number().describe("The price per unit for this tier"),
});
const ProductItemSchema = z.object({
feature_id: z
.string()
.nullish()
.describe(
"Feature ID this item relates to. Set to null for standalone flat-fee price items (e.g., subscription base price, one-time purchase price).",
),
included_usage: z
.number()
.or(z.literal("inf"))
.nullish()
.describe(
"Usage granted to the customer. Use WITHOUT price for free allocations. Use WITH usage_model and price for metered pricing.",
),
interval: ProductItemInterval.nullish().describe("Reset/billing interval"),
price: z
.number()
.nullish()
.describe(
"Price amount. When feature_id is null, this is a standalone flat fee. When feature_id is set with usage_model, this is the per-unit price.",
),
tiers: z
.array(PriceTierSchema)
.nullish()
.describe(
"Tiered pricing structure. Use instead of price for volume-based pricing. Each tier defines upper limit (to) and price per unit (amount).",
),
usage_model: UsageModel.nullish().describe(
"prepaid or pay_per_use. Required when pricing per unit of usage.",
),
billing_units: z
.number()
.nullish()
.describe("Units per price (e.g., $1 per 30 credits)"),
});
const FreeTrialSchema = z
.object({
length: z.number().describe("Length of free trial"),
duration: FreeTrialDuration.describe("Unit: day, month, or year"),
unique_fingerprint: z.boolean().default(false),
card_required: z.boolean().default(true),
})
.nullish();
const ProductSchema = z
.object({
id: z.string().describe("Unique ID (lowercase, hyphens allowed)"),
name: z.string().describe("Display name"),
is_add_on: z
.boolean()
.default(false)
.describe(
"Set to true if this product is an add-on or top-up, (can be purchased together with other base plans).",
),
is_default: z
.boolean()
.default(false)
.describe(
"Set to true ONLY if the items array is completely empty OR contains only items with price: null. ANY pricing items (including pay-per-use, overage charges, prepaid etc.) disqualifies a plan from being default.",
),
group: z
.string()
.default("")
.describe(
"A group to assign this plan to. Leave empty unless user is building pricing where a customer could subscribe to 2 or more types of plans at the same time.`",
),
items: z.array(ProductItemSchema).default([]),
free_trial: FreeTrialSchema,
})
.refine(
(data) => {
if (data.is_default) {
return data.items.every((item) => item.price == null);
}
return true;
},
{
message:
"Default plans cannot have priced items. All items must have price: null or undefined.",
path: ["is_default"],
},
)
.refine(
(data) => {
const usageBasedFeatureIds = new Set(
data.items
.filter((item) => item.feature_id != null && item.usage_model != null)
.map((item) => item.feature_id),
);
// Check if any other items reference the same feature_id
return !data.items.some(
(item) =>
item.feature_id != null &&
item.usage_model == null &&
usageBasedFeatureIds.has(item.feature_id),
);
},
{
message:
"Cannot have separate items for the same feature when one has usage-based pricing. Combine into a single item (e.g., 100 free, then $0.10 per additional).",
path: ["items"],
},
)
.refine(
(data) => {
return !data.items.some(
(item) => item.usage_model === "pay_per_use" && item.interval == null,
);
},
{
message:
"Pay-per-use pricing requires an interval. Set interval (e.g., 'month') for usage-based items.",
path: ["items"],
},
)
.refine(
(data) => {
return !data.items.some(
(item) =>
item.price != null &&
item.feature_id != null &&
item.usage_model == null,
);
},
{
message:
"Priced metered features require a usage_model. Set to 'pay_per_use' or 'prepaid'.",
path: ["items"],
},
);
export const OrganisationConfigurationSchema = z
.object({
features: z.array(FeatureSchema).default([]),
products: z.array(ProductSchema),
})
.refine((data) => validateOneDefaultPerGroup({ products: data.products }), {
message:
"Only one plan per group can have is_default: true, unless it also has a free trial with card_required: false.",
path: ["products"],
});
export type PricingConfig = z.infer<typeof OrganisationConfigurationSchema>;

View File

@@ -0,0 +1,176 @@
import type { Context } from "hono";
import { matchRoute } from "../../../honoMiddlewares/middlewareUtils";
import type { HonoEnv } from "../../../honoUtils/HonoEnv";
export enum RateLimitType {
General = "general",
Track = "track",
Check = "check",
Events = "events",
Attach = "attach",
ListProducts = "list_products",
}
export const getRateLimitType = (c: Context<HonoEnv>) => {
const method = c.req.method;
const path = c.req.path;
// Exact match patterns for track endpoints
const trackPatterns = [
{
method: "POST",
url: "/v1/events",
},
{
method: "POST",
url: "/v1/track",
},
];
// Patterns for check endpoints (including dynamic customer_id)
const checkPatterns = [
{
method: "POST",
url: "/v1/check",
},
{
method: "POST",
url: "/v1/entitled",
},
];
const getCustomerPatterns = [
{
method: "GET",
url: "/v1/customers/:customer_id",
},
{
method: "GET",
url: "/v1/customers/:customer_id/entities/:entity_id",
},
{
method: "POST",
url: "/v1/customers",
},
];
const eventsPatterns = [
{
method: "POST",
url: "/v1/events/list",
},
{
method: "POST",
url: "/v1/events/aggregate",
},
{
method: "POST",
url: "/v1/query",
},
];
const attachPatterns = [
{
method: "POST",
url: "/v1/attach",
},
];
const listProductsPatterns = [
{
method: "GET",
url: "/v1/products",
},
{
method: "GET",
url: "/v1/products_beta",
},
{
method: "GET",
url: "/v1/plans",
},
];
const patternMap: {
patterns: { method: string; url: string }[];
type: RateLimitType;
}[] = [
{ patterns: listProductsPatterns, type: RateLimitType.ListProducts },
{ patterns: attachPatterns, type: RateLimitType.Attach },
{ patterns: trackPatterns, type: RateLimitType.Track },
{
patterns: checkPatterns.concat(getCustomerPatterns),
type: RateLimitType.Check,
},
{ patterns: eventsPatterns, type: RateLimitType.Events },
];
for (const { patterns, type } of patternMap) {
if (
patterns.some((pattern) => matchRoute({ url: path, method, pattern }))
) {
return type;
}
}
return RateLimitType.General;
};
export enum RateLimitScope {
Org = "org",
Customer = "customer",
CustomerWithUrlFallback = "customer_with_url_fallback", // Check endpoint: tries body first, then URL param
}
export type RateLimitConfig = {
name: string;
limit: number;
windowMs: number;
notInRedis: boolean;
scope: RateLimitScope;
};
export const RATE_LIMIT_CONFIGS: Record<RateLimitType, RateLimitConfig> = {
[RateLimitType.General]: {
name: "general",
limit: 1000,
windowMs: 1000,
notInRedis: false,
scope: RateLimitScope.Org,
},
[RateLimitType.Track]: {
name: "track",
limit: 10000,
windowMs: 1000,
notInRedis: true,
scope: RateLimitScope.Customer,
},
[RateLimitType.Check]: {
name: "check",
limit: 10000,
windowMs: 1000,
notInRedis: true,
scope: RateLimitScope.CustomerWithUrlFallback,
},
[RateLimitType.Events]: {
name: "events",
limit: 5,
windowMs: 1000,
notInRedis: false,
scope: RateLimitScope.Customer,
},
[RateLimitType.Attach]: {
name: "attach",
limit: 5,
windowMs: 60000,
notInRedis: false,
scope: RateLimitScope.Customer,
},
[RateLimitType.ListProducts]: {
name: "list_products",
limit: 20,
windowMs: 1000,
notInRedis: false,
scope: RateLimitScope.Org,
},
};

View File

@@ -0,0 +1,104 @@
import { RedisStore } from "@hono-rate-limiter/redis";
import type { Context } from "hono";
import { rateLimiter } from "hono-rate-limiter";
import { redis } from "@/external/redis/initRedis";
import {
parseCustomerIdFromBody,
parseCustomerIdFromUrl,
} from "@/honoMiddlewares/analyticsMiddleware";
import type { HonoEnv } from "@/honoUtils/HonoEnv";
import {
RATE_LIMIT_CONFIGS,
type RateLimitConfig,
RateLimitScope,
type RateLimitType,
} from "./rateLimitConfigs";
// Helper to get rate limit key from context
const getRateLimitKeyFromContext = (c: Context): string => {
return (c as Context & { rateLimitKey?: string }).rateLimitKey ?? "unknown";
};
// Helper to set rate limit key in context
export const setRateLimitKeyInContext = (c: Context, key: string): void => {
(c as Context & { rateLimitKey: string }).rateLimitKey = key;
};
export const rateLimitFactory = ({
limit,
windowMs,
notInRedis,
}: Pick<RateLimitConfig, "limit" | "windowMs" | "notInRedis">): ReturnType<
typeof rateLimiter
> => {
return rateLimiter({
windowMs,
limit,
standardHeaders: "draft-6",
keyGenerator: getRateLimitKeyFromContext,
store: notInRedis
? undefined
: new RedisStore({
client: {
scriptLoad: (script: string) =>
redis.script("LOAD", script) as Promise<string>,
evalsha: <TArgs extends unknown[], TData = unknown>(
sha: string,
keys: string[],
args: TArgs,
): Promise<TData> => {
return redis.evalsha(
sha,
keys.length,
...keys,
...(args as (string | number | Buffer)[]),
) as Promise<TData>;
},
decr: (key: string) => redis.decr(key),
del: (key: string) => redis.del(key),
},
}),
});
};
// Create rate limiters from central config
const limiters = Object.fromEntries(
Object.entries(RATE_LIMIT_CONFIGS).map(([type, config]) => [
type,
rateLimitFactory(config),
]),
) as Record<RateLimitType, ReturnType<typeof rateLimiter>>;
export const getLimiterForType = (type: RateLimitType) => limiters[type];
export const getRateLimitKey = async ({
c,
rateLimitType,
}: {
c: Context<HonoEnv>;
rateLimitType: RateLimitType;
}): Promise<string> => {
const ctx = c.get("ctx");
const orgId = ctx.org?.id;
const env = ctx.env;
const config = RATE_LIMIT_CONFIGS[rateLimitType];
const baseKey = `${config.name}:${orgId}:${env}`;
switch (config.scope) {
case RateLimitScope.Org:
return baseKey;
case RateLimitScope.Customer: {
const res = await parseCustomerIdFromBody(c);
return `${baseKey}:${res?.customerId}`;
}
case RateLimitScope.CustomerWithUrlFallback: {
const res = await parseCustomerIdFromBody(c);
const urlCustomerId = parseCustomerIdFromUrl({ url: c.req.path });
const customerId = res?.customerId || urlCustomerId;
return `${baseKey}:${customerId}`;
}
}
};

View File

@@ -26,7 +26,10 @@ import {
sql,
} from "drizzle-orm";
import { StatusCodes } from "http-status-codes";
import { queryWithCache } from "@/utils/cacheUtils/queryWithCache";
import { buildProductsCacheKey, PRODUCTS_CACHE_TTL } from "./productCacheUtils";
import { getLatestProducts } from "./productUtils";
import { sortFullProducts } from "./productUtils/sortProductUtils";
const parseFreeTrials = ({
products,
@@ -214,7 +217,6 @@ export class ProductService {
version,
excludeEnts = false,
archived,
includeAll = false,
}: {
db: DrizzleCli;
orgId: string;
@@ -224,10 +226,54 @@ export class ProductService {
version?: number;
excludeEnts?: boolean;
archived?: boolean;
includeAll?: boolean;
}) {
}): Promise<FullProduct[]> {
// Use caching for simple queries (no inIds, returnAll, version, or excludeEnts)
const canCache = !inIds && !returnAll && !version && !excludeEnts;
if (canCache) {
return queryWithCache({
key: buildProductsCacheKey({
orgId,
env,
queryParams: { archived },
}),
ttl: PRODUCTS_CACHE_TTL,
fn: () => ProductService._listFullQuery({ db, orgId, env, archived }),
});
}
return ProductService._listFullQuery({
db,
orgId,
env,
inIds,
returnAll,
version,
excludeEnts,
archived,
});
}
private static async _listFullQuery({
db,
orgId,
env,
inIds,
returnAll = false,
version,
excludeEnts = false,
archived,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
inIds?: string[];
returnAll?: boolean;
version?: number;
excludeEnts?: boolean;
archived?: boolean;
}): Promise<FullProduct[]> {
// Optimization: Use a subquery to only fetch the latest version of each product
// This avoids fetching all versions and filtering in memory
const latestVersionsSubquery =
!returnAll && !version
? db
@@ -255,7 +301,6 @@ export class ProductService {
eq(products.env, env),
inIds ? inArray(products.id, inIds) : undefined,
version ? eq(products.version, version) : undefined,
// Only apply the version filter when we're not returning all versions
latestVersionsSubquery
? exists(
db
@@ -270,7 +315,6 @@ export class ProductService {
)
: undefined,
),
with: {
entitlements: excludeEnts
? undefined
@@ -305,11 +349,12 @@ export class ProductService {
return newProducts;
}
if (notNullish(archived)) {
return latestProducts.filter((p) => p.archived === archived);
}
const result = notNullish(archived)
? latestProducts.filter((p) => p.archived === archived)
: latestProducts;
return latestProducts as FullProduct[];
sortFullProducts({ products: result });
return result;
}
static async getFull({

View File

@@ -1,6 +1,7 @@
import { AppEnv } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { invalidateProductsCache } from "../../productCacheUtils.js";
import { handleCopyFeatures } from "./handleCopyFeatures.js";
import { handleCopyProducts } from "./handleCopyProducts.js";
@@ -45,6 +46,8 @@ export const handleCopyEnvironment = createRoute({
toEnv,
});
await invalidateProductsCache({ orgId: org.id, env: toEnv });
return c.json({
message: "Products copied to production",
});

View File

@@ -6,8 +6,8 @@ import {
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { invalidateProductsCache } from "@/internal/products/productCacheUtils.js";
import { copyProduct } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId } from "../../../../utils/genUtils";
@@ -134,6 +134,12 @@ export const handleCopyProductV2 = createRoute({
logger,
});
// Invalidate cache for target environment (and source if same org)
await invalidateProductsCache({ orgId: org.id, env: toEnv });
if (fromEnv !== toEnv) {
await invalidateProductsCache({ orgId: org.id, env: fromEnv });
}
return c.json({ message: "Product copied" });
},
});

View File

@@ -8,7 +8,6 @@ import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { CusService } from "../../customers/CusService";
import { ProductService } from "../ProductService";
import { getPlanResponse } from "../productUtils/productResponseUtils/getPlanResponse";
import { sortFullProducts } from "../productUtils/sortProductUtils";
export const handleListPlans = createRoute({
query: ListPlansQuerySchema,
@@ -20,6 +19,7 @@ export const handleListPlans = createRoute({
const { customer_id, entity_id, include_archived, v1_schema } = query;
const startedAt = Date.now();
const [products, customer] = await Promise.all([
ProductService.listFull({
db,
@@ -27,31 +27,25 @@ export const handleListPlans = createRoute({
env,
archived: include_archived ? undefined : false,
}),
(async () => {
if (!customer_id) {
return undefined;
}
return await CusService.getFull({
db,
idOrInternalId: customer_id,
orgId: org.id,
env,
entityId: entity_id,
withEntities: true,
withSubs: true,
allowNotFound: true,
});
})(),
customer_id
? CusService.getFull({
db,
idOrInternalId: customer_id,
orgId: org.id,
env,
entityId: entity_id,
withEntities: true,
withSubs: true,
allowNotFound: true,
})
: undefined,
]);
const endedAt = Date.now();
ctx.logger.info(`[handleListPlans] query took ${endedAt - startedAt}ms`);
ctx.logger.debug(`[handleListPlans] query took ${endedAt - startedAt}ms`);
if (v1_schema) return c.json({ list: products });
sortFullProducts({ products });
const batchResponse = [];
for (const p of products) {
batchResponse.push(

View File

@@ -116,12 +116,6 @@ export const handleUpdatePlan = createRoute({
curProduct: fullProduct,
});
validateDefaultFlag({
ctx,
body: v1_2Body,
curProduct: fullProduct,
});
await handleUpdateProductDetails({
db,
curProduct: fullProduct,
@@ -167,6 +161,7 @@ export const handleUpdatePlan = createRoute({
return c.json(newProduct);
}
// Product details (name, group, etc.) may have changed via handleUpdateProductDetails
return c.json(fullProduct);
}
@@ -211,8 +206,11 @@ export const handleUpdatePlan = createRoute({
// New full product
await initProductInStripe({
ctx,
db,
product: newFullProduct,
org,
env,
logger,
});
logger.info("Adding task to queue to detect base variant");
@@ -247,6 +245,7 @@ export const handleUpdatePlan = createRoute({
},
ctx,
});
return c.json(versionedResponse);
},
});

View File

@@ -1,7 +1,6 @@
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { ProductService } from "@/internal/products/ProductService";
import { getGroupToDefaults } from "@/internal/products/productUtils";
import { sortFullProducts } from "@/internal/products/productUtils/sortProductUtils";
import { mapToProductV2 } from "@/internal/products/productV2Utils";
/**
@@ -13,21 +12,10 @@ import { mapToProductV2 } from "@/internal/products/productV2Utils";
export const handleGetProducts = createRoute({
handler: async (c) => {
const { db, org, env, features } = c.get("ctx");
const products = await ProductService.listFull({
db,
orgId: org.id,
env: env,
});
// if (process.env.NODE_ENV === "development") {
// products = products.slice(0, 10);
// }
const products = await ProductService.listFull({ db, orgId: org.id, env });
sortFullProducts({ products });
const groupToDefaults = getGroupToDefaults({
defaultProds: products,
});
const groupToDefaults = getGroupToDefaults({ defaultProds: products });
return c.json({
products: products.map((p) =>

View File

@@ -0,0 +1,110 @@
import crypto from "node:crypto";
import type { AppEnv } from "@autumn/shared";
import {
getConfiguredRegions,
getRegionalRedis,
redis,
} from "@/external/redis/initRedis";
const PRODUCTS_CACHE_PREFIX = "products_full";
/** Cache version - bump when cache schema changes to auto-invalidate old entries */
const PRODUCTS_CACHE_VERSION = "1.0.0";
/** TTL for products cache: 1 day */
export const PRODUCTS_CACHE_TTL = 60 * 60 * 24;
/** Hashes query params to create a short, consistent cache key suffix */
const hashQueryParams = (params: Record<string, unknown>): string => {
// Filter out undefined/null values and sort keys for consistency
const filtered = Object.entries(params)
.filter(([_, v]) => v !== undefined && v !== null)
.sort(([a], [b]) => a.localeCompare(b));
if (filtered.length === 0) return "default";
const str = JSON.stringify(filtered);
return crypto.createHash("md5").update(str).digest("hex").slice(0, 12);
};
/**
* Builds the base cache key prefix for products list (without query hash).
* Uses Redis hash tag {orgId} to ensure all keys for the same org hash to the same slot,
* enabling multi-key operations (like DEL) in Redis Cluster.
*/
export const buildProductsCacheKeyPrefix = ({
orgId,
env,
}: {
orgId: string;
env: AppEnv;
}) => {
return `${PRODUCTS_CACHE_PREFIX}:{${orgId}}:${env}:${PRODUCTS_CACHE_VERSION}`;
};
/** Builds the cache key for products list with optional query params */
export const buildProductsCacheKey = ({
orgId,
env,
queryParams,
}: {
orgId: string;
env: AppEnv;
queryParams?: Record<string, unknown>;
}) => {
const prefix = buildProductsCacheKeyPrefix({ orgId, env });
const hash = queryParams ? hashQueryParams(queryParams) : "default";
return `${prefix}:${hash}`;
};
/** All possible archived query param values that can be cached */
const ARCHIVED_VARIANTS = [undefined, false, true] as const;
/** Invalidates all products cache entries for an org/env across ALL regions */
export const invalidateProductsCache = async ({
orgId,
env,
}: {
orgId: string;
env: AppEnv;
}): Promise<void> => {
if (redis.status !== "ready") return;
// Build all possible cache keys (deterministic based on archived param variants)
const keysToDelete = ARCHIVED_VARIANTS.map((archived) =>
buildProductsCacheKey({
orgId,
env,
queryParams: archived !== undefined ? { archived } : undefined,
}),
);
const regions = getConfiguredRegions();
// Delete from all regions in parallel
const deletePromises = regions.map(async (region) => {
try {
const regionalRedis = getRegionalRedis(region);
if (regionalRedis.status !== "ready") {
console.warn(`[invalidateProductsCache] ${region}: not_ready`);
return { region, deleted: 0 };
}
const deleted = await regionalRedis.del(...keysToDelete);
console.info(
`[invalidateProductsCache] ${region}: deleted ${deleted} keys, org: ${orgId}, env: ${env}`,
);
return { region, deleted };
} catch (error) {
console.error(
`[invalidateProductsCache] ${region}: error, org: ${orgId}, env: ${env}, error: ${error}`,
);
return { region, deleted: 0 };
}
});
await Promise.all(deletePromises);
};

View File

@@ -6,6 +6,7 @@ import { z } from "zod";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { nullish } from "@/utils/genUtils.js";
import { ProductService } from "../ProductService.js";
import { invalidateProductsCache } from "../productCacheUtils.js";
const prompt = `Detect whether a given product (called "product_to_detect") is an interval variant of a base product from the list of existing products (called "existing_products").
@@ -115,6 +116,11 @@ export const detectBaseVariant = async ({
base_variant_id: baseVariantId,
},
});
await invalidateProductsCache({
orgId: curProduct.org_id,
env: curProduct.env,
});
}
return baseVariantId;

View File

@@ -1,6 +1,7 @@
await import("../sentry.js");
import {
DeleteMessageBatchCommand,
DeleteMessageCommand,
type Message,
ReceiveMessageCommand,
@@ -214,21 +215,34 @@ let isRunning = true;
const isFifoQueue = QUEUE_URL.endsWith(".fifo");
let abortController: AbortController;
// Tracking for periodic stats
let messagesProcessed = 0;
let lastStatsTime = Date.now();
/**
* Single SQS polling loop - runs continuously until shutdown
*/
const startPollingLoop = async ({ db }: { db: DrizzleCli }) => {
console.log(`[Process ${process.pid}] SQS poller started`);
console.log(`[SQS Worker ${process.pid}] Started polling ${QUEUE_URL}`);
abortController = new AbortController();
// Log stats every 60 seconds
const statsInterval = setInterval(() => {
const elapsed = ((Date.now() - lastStatsTime) / 1000).toFixed(0);
console.log(
`[SQS Worker ${process.pid}] Processed ${messagesProcessed} messages in ${elapsed}s`,
);
messagesProcessed = 0;
lastStatsTime = Date.now();
}, 60000);
while (isRunning) {
try {
const command = new ReceiveMessageCommand({
QueueUrl: QUEUE_URL,
MaxNumberOfMessages: 10, // Receive up to 10 messages at once
WaitTimeSeconds: 20, // Long polling
VisibilityTimeout: 30, // 12 hours (max) - prevents duplicate processing of long-running jobs
// For FIFO queues, add ReceiveRequestAttemptId for deduplication
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
VisibilityTimeout: 30,
...(isFifoQueue && {
ReceiveRequestAttemptId: generateId("receive"),
}),
@@ -239,14 +253,16 @@ const startPollingLoop = async ({ db }: { db: DrizzleCli }) => {
});
if (response.Messages && response.Messages.length > 0) {
// Process all messages concurrently
// Track messages to batch delete (excludes migration jobs which are deleted immediately)
const toDelete: { Id: string; ReceiptHandle: string }[] = [];
await Promise.allSettled(
response.Messages.map(async (message) => {
// Check if we should stop before processing
if (!isRunning || !message.Body) return;
// If migration job, return success immediately to avoid duplicate processing
const job: SqsJob = JSON.parse(message.Body);
// Migration jobs: delete IMMEDIATELY before processing (long-running, avoid timeout redelivery)
if (job.name === JobName.Migration) {
logger.info(
`Returning success immediately for migration job ${job.data.migrationJobId}`,
@@ -261,6 +277,7 @@ const startPollingLoop = async ({ db }: { db: DrizzleCli }) => {
try {
await processMessage({ message, db });
messagesProcessed++;
} catch (error) {
if (error instanceof Error) {
logger.error(
@@ -269,40 +286,49 @@ const startPollingLoop = async ({ db }: { db: DrizzleCli }) => {
}
}
// Always delete message, even on error (receive once only)
if (message.ReceiptHandle) {
try {
await sqs.send(
new DeleteMessageCommand({
QueueUrl: QUEUE_URL,
ReceiptHandle: message.ReceiptHandle,
}),
);
} catch (deleteError: any) {
console.error(
`Failed to delete message ${message.MessageId}:`,
deleteError.message,
);
}
// Queue for batch delete (skip migration jobs - already deleted)
if (message.ReceiptHandle && job.name !== JobName.Migration) {
toDelete.push({
Id: message.MessageId!,
ReceiptHandle: message.ReceiptHandle,
});
}
}),
);
// Batch delete all non-migration messages
if (toDelete.length > 0) {
try {
await sqs.send(
new DeleteMessageBatchCommand({
QueueUrl: QUEUE_URL,
Entries: toDelete,
}),
);
} catch (deleteError: any) {
console.error(
`[SQS Worker ${process.pid}] Batch delete failed: ${deleteError.message}`,
);
}
}
}
} catch (error: any) {
// Ignore abort errors during shutdown
if (error.name === "AbortError" || error.name === "RequestAbortedError") {
console.log(`[SQS Worker ${process.pid}] Polling aborted (shutdown)`);
break;
}
if (isRunning) {
console.error("SQS polling error:", error.message);
// Wait a bit before retrying after an error
console.error(
`[SQS Worker ${process.pid}] Polling error: ${error.message}`,
);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
}
console.log("SQS poller stopped");
clearInterval(statsInterval);
console.log(`[SQS Worker ${process.pid}] Stopped`);
};
/**
@@ -312,26 +338,15 @@ const startPollingLoop = async ({ db }: { db: DrizzleCli }) => {
export const initWorkers = async () => {
const { db } = initDrizzle({ maxConnections: 3 });
// Graceful shutdown handler
const shutdown = async () => {
console.log("Shutting down SQS poller...");
console.log(`[SQS Worker ${process.pid}] Shutting down...`);
isRunning = false;
if (abortController) abortController.abort();
// Abort in-flight SQS request
if (abortController) {
abortController.abort();
}
// In production, give 5 seconds to finish current message processing
// In development, exit immediately for faster hot reloads
const isProd = process.env.NODE_ENV === "production";
if (isProd) {
setTimeout(() => {
console.log("Shutdown timeout reached, forcing exit");
process.exit(0);
}, 5000);
setTimeout(() => process.exit(0), 5000);
} else {
console.log("Development mode: exiting immediately");
process.exit(0);
}
};
@@ -339,7 +354,6 @@ export const initWorkers = async () => {
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
// Start the single polling loop
await startPollingLoop({ db });
};

View File

@@ -11,6 +11,7 @@ import { orgConfigMiddleware } from "../honoMiddlewares/orgConfigMiddleware.js";
import { queryMiddleware } from "../honoMiddlewares/queryMiddleware.js";
import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware.js";
import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware.js";
import { refreshProductsCacheMiddleware } from "../honoMiddlewares/refreshProductsCacheMiddleware.js";
import { secretKeyMiddleware } from "../honoMiddlewares/secretKeyMiddleware.js";
import type { HonoEnv } from "../honoUtils/HonoEnv.js";
import {
@@ -39,6 +40,7 @@ apiRouter.use("*", secretKeyMiddleware);
apiRouter.use("*", orgConfigMiddleware);
apiRouter.use("*", apiVersionMiddleware);
apiRouter.use("*", refreshCacheMiddleware);
apiRouter.use("*", refreshProductsCacheMiddleware);
apiRouter.use("*", analyticsMiddleware);
apiRouter.use("*", rateLimitMiddleware);
apiRouter.use("*", queryMiddleware());

View File

@@ -0,0 +1,58 @@
import { expect, test } from "bun:test";
import type {
ApiBalance,
ApiBalanceBreakdown,
ApiCusFeatureV3Breakdown,
ApiCustomer,
ApiCustomerV3,
} from "@shared/index";
import { TestFeature } from "@tests/setup/v2Features";
import { initScenario } from "@tests/utils/testInitUtils/initScenario";
test.concurrent("loose-expiry-cross-version", async () => {
const customerId = "loose-expiry-cross-version";
const { autumnV2, autumnV1 } = await initScenario({
customerId,
setup: [],
actions: [],
});
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 100,
expires_at: Date.now() + 1000,
});
const V1Cust = (await autumnV1.customers.get(
customerId,
)) as unknown as ApiCustomerV3;
const V2Cust = (await autumnV2.customers.get(
customerId,
)) as unknown as ApiCustomer;
const V1Bal = V1Cust.features[TestFeature.Messages] ?? null;
const V1Breakdown = V1Bal?.breakdown?.find(
(x: ApiCusFeatureV3Breakdown) =>
x.expires_at !== null && x.expires_at !== undefined,
);
expect(V1Bal).toBeDefined();
expect(V1Breakdown).toBeDefined();
expect(V1Breakdown?.expires_at).toBeDefined();
expect(V1Breakdown?.expires_at).toBeGreaterThan(Date.now());
const V2Bal = (V2Cust.balances[TestFeature.Messages] ??
null) as unknown as ApiBalance;
const V2Breakdown = V2Bal?.breakdown?.find(
(x: ApiBalanceBreakdown) =>
x.expires_at !== null && x.expires_at !== undefined,
);
expect(V2Bal).toBeDefined();
expect(V2Breakdown).toBeDefined();
expect(V2Breakdown?.expires_at).toBeDefined();
expect(V2Breakdown?.expires_at).toBeGreaterThan(Date.now());
expect(V1Breakdown?.expires_at).toBe(V2Breakdown?.expires_at);
});

View File

@@ -3,7 +3,7 @@ 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 { eventActions } from "@/internal/analytics/actions/index.js";
import { eventActions } from "@/internal/analytics/actions/eventActions.js";
import { generateId, timeout } from "@/utils/genUtils.js";
const free = products.base({

View File

@@ -0,0 +1,129 @@
import { expect, test } from "bun:test";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import AutumnError from "@/external/autumn/autumnCli.js";
const testCase = "rate-limit-attach";
const customerId = `test-${testCase}`;
// Attach rate limit is 5 per minute per customer
const ATTACH_RATE_LIMIT = 5;
/**
* Test: Rate limit on /attach endpoint
* Note: Attach rate limiting is bypassed in dev/test for the test org (see rateLimitMiddleware.ts)
* This test is skipped because it cannot be tested in the test environment.
*/
test.skip(`${chalk.yellowBright(testCase)}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
// Create multiple add-on products so we can attach them
const addon1 = products.base({
id: "addon1",
items: [messagesItem],
isAddOn: true,
});
const addon2 = products.base({
id: "addon2",
items: [messagesItem],
isAddOn: true,
});
const addon3 = products.base({
id: "addon3",
items: [messagesItem],
isAddOn: true,
});
const addon4 = products.base({
id: "addon4",
items: [messagesItem],
isAddOn: true,
});
const addon5 = products.base({
id: "addon5",
items: [messagesItem],
isAddOn: true,
});
const addon6 = products.base({
id: "addon6",
items: [messagesItem],
isAddOn: true,
});
const addon7 = products.base({
id: "addon7",
items: [messagesItem],
isAddOn: true,
});
const addon8 = products.base({
id: "addon8",
items: [messagesItem],
isAddOn: true,
});
const baseProduct = products.base({ id: "base", items: [messagesItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({
list: [
baseProduct,
addon1,
addon2,
addon3,
addon4,
addon5,
addon6,
addon7,
addon8,
],
}),
],
actions: [s.attach({ productId: baseProduct.id })],
});
// Fire off more attach requests than the rate limit allows
const addons = [
addon1,
addon2,
addon3,
addon4,
addon5,
addon6,
addon7,
addon8,
];
const requestCount = ATTACH_RATE_LIMIT + 3;
const results = await Promise.allSettled(
addons.slice(0, requestCount).map((addon) =>
autumnV1.attach({
customer_id: customerId,
product_id: addon.id,
}),
),
);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const rateLimitedCount = results.filter(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
).length;
console.log(
`Requests: ${requestCount}, Successes: ${successCount}, Rate limited: ${rateLimitedCount}`,
);
expect(rateLimitedCount).toBeGreaterThan(0);
const rateLimitedResult = results.find(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
);
expect(rateLimitedResult).toBeDefined();
});

View File

@@ -0,0 +1,66 @@
import { expect, test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import AutumnError from "@/external/autumn/autumnCli.js";
const testCase = "rate-limit-events-aggregate";
const customerId = `test-${testCase}`;
// Events rate limit is 5 per second per customer
const EVENTS_RATE_LIMIT = 5;
/**
* Test: Rate limit on events/aggregate endpoint
*/
test(`${chalk.yellowBright(testCase)}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const baseProduct = products.base({ id: "base", items: [messagesItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({ list: [baseProduct] }),
],
actions: [
s.attach({ productId: baseProduct.id }),
s.track({ featureId: TestFeature.Messages, value: 10 }),
s.track({ featureId: TestFeature.Messages, value: 20 }),
],
});
await new Promise((resolve) => setTimeout(resolve, 1000));
const requestCount = EVENTS_RATE_LIMIT + 3;
const results = await Promise.allSettled(
Array.from({ length: requestCount }, () =>
autumnV1.events.aggregate({ customer_id: customerId }),
),
);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const rateLimitedCount = results.filter(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
).length;
console.log(
`Requests: ${requestCount}, Successes: ${successCount}, Rate limited: ${rateLimitedCount}`,
);
expect(rateLimitedCount).toBeGreaterThan(0);
const rateLimitedResult = results.find(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
);
expect(rateLimitedResult).toBeDefined();
});

View File

@@ -0,0 +1,66 @@
import { expect, test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import AutumnError from "@/external/autumn/autumnCli.js";
const testCase = "rate-limit-events-list";
const customerId = `test-${testCase}`;
// Events rate limit is 5 per second per customer
const EVENTS_RATE_LIMIT = 5;
/**
* Test: Rate limit on events/list endpoint
*/
test(`${chalk.yellowBright(testCase)}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const baseProduct = products.base({ id: "base", items: [messagesItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({ list: [baseProduct] }),
],
actions: [
s.attach({ productId: baseProduct.id }),
s.track({ featureId: TestFeature.Messages, value: 10 }),
s.track({ featureId: TestFeature.Messages, value: 20 }),
],
});
await new Promise((resolve) => setTimeout(resolve, 1000));
const requestCount = EVENTS_RATE_LIMIT + 3;
const results = await Promise.allSettled(
Array.from({ length: requestCount }, () =>
autumnV1.events.list({ customer_id: customerId }),
),
);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const rateLimitedCount = results.filter(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
).length;
console.log(
`Requests: ${requestCount}, Successes: ${successCount}, Rate limited: ${rateLimitedCount}`,
);
expect(rateLimitedCount).toBeGreaterThan(0);
const rateLimitedResult = results.find(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
);
expect(rateLimitedResult).toBeDefined();
});

View File

@@ -0,0 +1,66 @@
import { expect, test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import AutumnError from "@/external/autumn/autumnCli.js";
const testCase = "rate-limit-events-query";
const customerId = `test-${testCase}`;
// Events rate limit is 5 per second per customer
const EVENTS_RATE_LIMIT = 5;
/**
* Test: Rate limit on /query endpoint
*/
test(`${chalk.yellowBright(testCase)}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const baseProduct = products.base({ id: "base", items: [messagesItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({ list: [baseProduct] }),
],
actions: [
s.attach({ productId: baseProduct.id }),
s.track({ featureId: TestFeature.Messages, value: 10 }),
s.track({ featureId: TestFeature.Messages, value: 20 }),
],
});
await new Promise((resolve) => setTimeout(resolve, 1000));
const requestCount = EVENTS_RATE_LIMIT + 3;
const results = await Promise.allSettled(
Array.from({ length: requestCount }, () =>
autumnV1.events.query({ customer_id: customerId }),
),
);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const rateLimitedCount = results.filter(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
).length;
console.log(
`Requests: ${requestCount}, Successes: ${successCount}, Rate limited: ${rateLimitedCount}`,
);
expect(rateLimitedCount).toBeGreaterThan(0);
const rateLimitedResult = results.find(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
);
expect(rateLimitedResult).toBeDefined();
});

View File

@@ -0,0 +1,49 @@
import { expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
const testCase = "rate-limit-list-products";
// ListProducts rate limit is 20 per second per org
const LIST_PRODUCTS_RATE_LIMIT = 20;
/**
* Test: Rate limit on GET /products endpoint
*/
test(`${chalk.yellowBright(testCase)}`, async () => {
const autumnV1 = new AutumnInt({
version: ApiVersion.V1_2,
secretKey: ctx.orgSecretKey,
});
// Fire off more requests than the rate limit allows
const requestCount = LIST_PRODUCTS_RATE_LIMIT + 5;
const results = await Promise.allSettled(
Array.from({ length: requestCount }, () => autumnV1.get("/products")),
);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const rateLimitedCount = results.filter(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
).length;
console.log(
`Requests: ${requestCount}, Successes: ${successCount}, Rate limited: ${rateLimitedCount}`,
);
expect(rateLimitedCount).toBeGreaterThan(0);
const rateLimitedResult = results.find(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
);
expect(rateLimitedResult).toBeDefined();
});

View File

@@ -0,0 +1,39 @@
import { test } from "bun:test";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
/**
* Attach Paid Default Plan Scenario
*
* Sets up a customer with a paid default product attached on creation.
* Paid defaults require a trial with cardRequired: false.
*
* Setup:
* - Paid default product: $20/month with 100 messages, 7-day trial, no card required
* - Customer with withDefault: true
*/
test(`${chalk.yellowBright("attach-paid-default: customer with paid default product (trial, no card required)")}`, async () => {
const customerId = "attach-paid-default";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const paidDefault = products.defaultTrial({
id: "paid-default",
items: [messagesItem],
trialDays: 7,
cardRequired: false,
});
await initScenario({
customerId,
setup: [
s.products({ list: [paidDefault] }),
s.customer({ withDefault: true }),
s.attachPaymentMethod({ type: "success" }),
s.advanceToNextInvoice(),
],
actions: [],
});
});

View File

@@ -1,131 +0,0 @@
import { expect, test } from "bun:test";
import type { ConfirmCheckoutResponse } from "@autumn/shared";
import { removeAllPaymentMethods } from "@/external/stripe/customers/paymentMethods/operations/removeAllPaymentMethods";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import axios from "axios";
import chalk from "chalk";
/**
* Confirm Paid Product Without Payment Method Scenario
*
* Tests confirming an Autumn checkout for a paid product when the customer
* has no payment method on file. This can happen when:
* 1. Customer had PM when checkout was created, then removed it
* 2. Or the checkout requires payment collection
*
* Expected behavior: Should return a payment_url for the customer to complete payment.
*/
test(
`${chalk.yellowBright("autumn-checkout: confirm paid (no PM) - Returns payment_url")}`,
async () => {
const customerId = "checkout-confirm-paid-no-pm";
// Pro plan ($20/mo)
const pro = products.pro({
id: "pro",
items: [items.dashboard(), items.monthlyMessages({ includedUsage: 500 })],
});
// Setup: customer WITH payment method initially (to get autumn checkout)
const { autumnV1, customer } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }), // Start with PM to get autumn checkout
s.products({ list: [pro] }),
],
actions: [],
});
// 1. Create checkout with redirect_mode: "always" (Autumn checkout)
// This works because customer has PM
const attachResult = await autumnV1.billing.attach({
customer_id: customerId,
product_id: `pro_${customerId}`,
redirect_mode: "always",
});
console.log("attach result:", attachResult);
// Should return autumn checkout URL
const checkoutUrl = attachResult.checkout_url;
expect(checkoutUrl).toBeDefined();
expect(checkoutUrl).toContain("/c/");
// Extract checkout ID
const checkoutId = checkoutUrl!.split("/c/")[1];
console.log("checkout ID:", checkoutId);
// 2. Remove payment method AFTER checkout was created
// This simulates the scenario where customer no longer has a PM when confirming
const stripeCustomerId = customer?.processor?.id;
if (stripeCustomerId) {
await removeAllPaymentMethods({
stripeClient: ctx.stripeCli,
stripeCustomerId,
});
console.log("Removed all payment methods from customer");
}
// 3. Verify customer doesn't have product yet
const customerBefore = await autumnV1.customers.get(customerId);
console.log("customer before confirm (no PM):", {
products: customerBefore.products?.map(
(p: { id: string; name: string | null }) => ({
id: p.id,
name: p.name,
}),
),
});
// 4. Attempt to confirm the checkout without PM
// Should return payment_url since payment is required but no PM exists
try {
const confirmResponse = await axios.post<
ConfirmCheckoutResponse & { payment_url?: string; checkout_url?: string }
>(
`http://localhost:8080/checkouts/${checkoutId}/confirm`,
{},
{ timeout: 10000 },
);
const confirmData = confirmResponse.data;
console.log("confirm response:", confirmData);
// If confirm succeeds, it should include a payment_url or checkout_url
// for the customer to complete payment
const paymentUrl = confirmData.payment_url || confirmData.checkout_url;
if (paymentUrl) {
expect(paymentUrl).toBeDefined();
console.log("payment/checkout url returned:", paymentUrl);
} else {
// If no payment_url, the confirm might still succeed
// and create a subscription that requires payment
console.log("confirm succeeded without payment_url");
console.log("invoice_id:", confirmData.invoice_id);
}
} catch (error: unknown) {
// It's also acceptable for the confirm to fail with an error
// requiring payment method
if (axios.isAxiosError(error)) {
console.log("confirm error status:", error.response?.status);
console.log("confirm error data:", error.response?.data);
const errorData = error.response?.data;
// Check if error includes payment_url for payment collection
if (errorData?.payment_url) {
expect(errorData.payment_url).toBeDefined();
console.log("payment_url in error response:", errorData.payment_url);
} else {
// Should indicate payment method is required or similar
console.log("Error code:", errorData?.code);
console.log("Error message:", errorData?.message);
}
} else {
throw error;
}
}
},
{ timeout: 30000 },
);

View File

@@ -1,4 +1,5 @@
import { test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";

View File

@@ -1,6 +1,6 @@
DESCRIPTION >
Materialized view of events sorted by timestamp for fast time-range queries.
Used by list_events.pipe for the raw logs viewer UI.
Used by list_events_paginated.pipe for the raw logs viewer UI and external API.
Sorting key: (org_id, env, timestamp, customer_id, event_name)
SCHEMA >

View File

@@ -1,4 +1,6 @@
DESCRIPTION >
DEPRECATED: Use list_events_paginated.pipe instead.
Kept for backwards compatibility during migration.
Lists raw events with filtering by org, env, customer, and date range.
Optimized for the raw logs viewer UI. Supports pagination via cursor.
Queries events_by_timestamp_mv which is sorted by (org_id, env, timestamp) for fast time-range queries.

View File

@@ -0,0 +1,36 @@
DESCRIPTION >
Lists raw events with offset-based pagination for external API.
Supports filtering by customer_id, event_names (array), and optional date range.
TOKEN "list_events_paginated_read" READ
NODE endpoint
TYPE endpoint
SQL >
%
SELECT
id,
customer_id,
event_name,
timestamp,
value,
properties
FROM events_by_timestamp_mv
WHERE
org_id = {{ String(org_id, '') }}
AND env = {{ String(env, 'test') }}
{% if defined(start_date) and String(start_date, '') != '' %}
AND timestamp >= toDateTime64({{ String(start_date) }}, 6)
{% end %}
{% if defined(end_date) and String(end_date, '') != '' %}
AND timestamp <= toDateTime64({{ String(end_date) }}, 6)
{% end %}
{% if defined(customer_id) and String(customer_id, '') != '' %}
AND customer_id = {{ String(customer_id) }}
{% end %}
{% if defined(event_names) %}
AND event_name IN {{ Array(event_names, 'String') }}
{% end %}
ORDER BY timestamp DESC, id DESC
LIMIT {{ Int32(limit, 101) }}
OFFSET {{ Int32(offset, 0) }}

View File

@@ -1,8 +1,9 @@
import type { ZodOpenApiPathsObject } from "zod-openapi";
import { SuccessResponseSchema } from "../../../common/commonResponses.js";
import { CreateBalanceParamsSchema } from "../../../models.js";
import { xCodeSamplesLegacy } from "../../../utils/xCodeSamplesLegacy.js";
export const balancesOpenApi = {
export const balancesOpenApi: ZodOpenApiPathsObject = {
"/balances/create": {
post: {
summary: "Create Balance",

View File

@@ -4,4 +4,4 @@ export const TrackLegacyDataSchema = z.object({
feature_id: z.string(),
});
type TrackLegacyData = z.infer<typeof TrackLegacyDataSchema>;
export type TrackLegacyData = z.infer<typeof TrackLegacyDataSchema>;

View File

@@ -0,0 +1,13 @@
import { z } from "zod/v4";
import { CheckoutChangeSchema } from "../../../internal/checkout/checkoutResponses.js";
import { BillingPreviewResponseSchema } from "./billingPreviewResponse.js";
/**
* Attach preview response - extends BillingPreviewResponse with incoming/outgoing changes
*/
export const AttachPreviewResponseSchema = BillingPreviewResponseSchema.extend({
incoming: z.array(CheckoutChangeSchema),
outgoing: z.array(CheckoutChangeSchema),
});
export type AttachPreviewResponse = z.infer<typeof AttachPreviewResponseSchema>;

View File

@@ -0,0 +1,11 @@
/** Converts epoch ms to ClickHouse DateTime string format */
export const epochToDateTime = (epochMs: number): string => {
const date = new Date(epochMs);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hours = String(date.getUTCHours()).padStart(2, "0");
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};

View File

@@ -160,7 +160,18 @@ const toV3BalanceParams = ({
? new Decimal(input.max_purchase).add(includedUsage).toNumber()
: undefined;
return { includedUsage, balance, usage, overageAllowed, usageLimit };
// 6. Expires at
const expiresAt =
"expires_at" in input ? (input as ApiBalanceBreakdown).expires_at : null;
return {
includedUsage,
balance,
usage,
overageAllowed,
usageLimit,
expiresAt,
};
};
export function transformBalanceToCusFeatureV3({
@@ -202,14 +213,20 @@ export function transformBalanceToCusFeatureV3({
unlimited: isUnlimited,
});
const { includedUsage, balance, usage, overageAllowed, usageLimit } =
toV3BalanceParams({
input: breakdown,
feature,
unlimited: isUnlimited,
legacyData,
isBreakdown: true,
});
const {
includedUsage,
balance,
usage,
overageAllowed,
usageLimit,
expiresAt,
} = toV3BalanceParams({
input: breakdown,
feature,
unlimited: isUnlimited,
legacyData,
isBreakdown: true,
});
return {
interval: interval === "multiple" || !interval ? null : interval,
@@ -222,6 +239,7 @@ export function transformBalanceToCusFeatureV3({
next_reset_at: next_reset_at,
usage_limit: usageLimit,
overage_allowed: overageAllowed,
expires_at: expiresAt,
} satisfies ApiCusFeatureV3Breakdown;
});
}

View File

@@ -17,6 +17,8 @@ const breakdownDescriptions = {
"The maximum usage allowed for this feature. null if unlimited or no limit is set",
overage_allowed:
"Whether the customer can continue using the feature beyond the usage limit. If false, access is blocked when limit is reached",
expires_at:
"Unix timestamp (in milliseconds) when the balance will expire. Only present for loose entitlements",
};
const coreFeatureDescriptions = {
@@ -74,7 +76,9 @@ export const ApiCusFeatureV3BreakdownSchema = z.object({
usage_limit: z.number().nullish().meta({
description: breakdownDescriptions.usage_limit,
}),
expires_at: z.number().nullish().meta({
description: breakdownDescriptions.expires_at,
}),
overage_allowed: z.boolean().nullish().meta({
description: breakdownDescriptions.overage_allowed,
}),

View File

@@ -4,8 +4,10 @@ export { schemas };
export * from "./api/apiUtils.js";
// Billing common schemas
export * from "./api/billing/common/attachPreviewResponse.js";
export * from "./api/billing/common/billingBehavior.js";
export * from "./api/billing/common/billingPreviewResponse.js";
export * from "./api/billing/common/billingResponse.js";
export * from "./api/billing/common/cancelAction.js";
// Cursor pagination utilities
export * from "./api/common/cursorPaginationSchemas.js";
@@ -171,6 +173,8 @@ export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js"
export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js";
export * from "./models/subModels/subModels.js";
export * from "./models/subModels/subTable.js";
// Agent Types (for pricing agent AI)
export * from "./utils/agentTypes.js";
export * from "./utils/billingUtils/index.js";
// Checkout Utils
export * from "./utils/checkoutUtils/index.js";

View File

@@ -0,0 +1,34 @@
import z from "zod/v4";
import {
EnrichedNewProductActionSchema,
type NewProductAction,
NewProductActionSchema,
} from "./newProductAction";
import {
type OngoingCusProductAction,
OngoingCusProductActionSchema,
} from "./ongoingCusProductAction";
import {
type ScheduledCusProductAction,
ScheduledCusProductActionSchema,
} from "./scheduledCusProductAction";
export interface CusProductActions {
ongoingCusProductAction?: OngoingCusProductAction;
scheduledCusProductAction?: ScheduledCusProductAction;
newProductActions: NewProductAction[];
}
export const CusProductActionsSchema: z.ZodObject = z.object({
ongoingCusProductAction: OngoingCusProductActionSchema,
scheduledCusProductAction: ScheduledCusProductActionSchema,
newProductActions: z.array(NewProductActionSchema),
});
export const EnrichedCusProductActionsSchema: z.ZodObject =
CusProductActionsSchema.extend({
ongoingCusProductAction: OngoingCusProductActionSchema,
scheduledCusProductAction: ScheduledCusProductActionSchema,
newProductActions: z.array(EnrichedNewProductActionSchema),
});

View File

@@ -29,6 +29,7 @@ export type TimeseriesEventsParams = TotalEventsParams & {
group_by?: string;
no_count?: boolean;
timezone?: string;
enforceGroupLimit?: boolean;
};
export type CalculateDateRangeParams = Omit<

311
shared/utils/agentTypes.ts Normal file
View File

@@ -0,0 +1,311 @@
/**
* Agent Types - Types and converters for the AI pricing agent
*
* The "Agent" format is a simplified, AI-friendly format used by the pricing agent.
* It uses string literals like "single_use" instead of enums, making it easier for
* LLMs to generate and for users to read.
*
* This module provides:
* - TypeScript interfaces for the agent format
* - Converters: AgentFeature ↔ Feature, AgentProduct ↔ ProductV2
*/
import {
FeatureType,
FeatureUsageType,
} from "../models/featureModels/featureEnums.js";
import type { Feature } from "../models/featureModels/featureModels.js";
import { AppEnv } from "../models/genModels/genEnums.js";
import { Infinite } from "../models/productModels/productEnums.js";
import type { ProductItem } from "../models/productV2Models/productItemModels/productItemModels.js";
import type { ProductV2 } from "../models/productV2Models/productV2Models.js";
// ============ INTERFACES ============
export type AgentFeatureType =
| "static"
| "boolean"
| "single_use"
| "continuous_use"
| "credit_system";
export interface AgentFeature {
id: string;
name?: string | null;
type: AgentFeatureType;
display?: {
singular: string;
plural: string;
} | null;
credit_schema?: Array<{
metered_feature_id: string;
credit_cost: number;
}> | null;
}
export interface AgentProductItem {
feature_id?: string | null;
included_usage?: number | "inf" | null;
interval?: string | null;
price?: number | null;
tiers?: Array<{ to: number | "inf"; amount: number }> | null;
usage_model?: "prepaid" | "pay_per_use" | null;
billing_units?: number | null;
}
export interface AgentFreeTrial {
length: number;
duration: "day" | "month" | "year";
unique_fingerprint?: boolean;
card_required?: boolean;
}
export interface AgentProduct {
id: string;
name: string;
is_add_on?: boolean;
is_default?: boolean;
group?: string;
items?: AgentProductItem[];
free_trial?: AgentFreeTrial | null;
}
export interface AgentPricingConfig {
features: AgentFeature[];
products: AgentProduct[];
}
// ============ AGENT → SHARED CONVERTERS ============
function mapAgentTypeToFeatureType(agentType: AgentFeatureType): FeatureType {
switch (agentType) {
case "boolean":
case "static":
return FeatureType.Boolean;
case "credit_system":
return FeatureType.CreditSystem;
default:
return FeatureType.Metered;
}
}
function mapAgentTypeToUsageType(
agentType: AgentFeatureType,
): FeatureUsageType | null {
switch (agentType) {
case "single_use":
return FeatureUsageType.Single;
case "continuous_use":
return FeatureUsageType.Continuous;
default:
return null;
}
}
/** Convert AgentFeature → Feature (shared DB type) */
export function agentFeatureToFeature(agentFeature: AgentFeature): Feature {
const usageType = mapAgentTypeToUsageType(agentFeature.type);
const config: Record<string, unknown> = {};
if (usageType) {
config.usage_type = usageType;
}
if (agentFeature.credit_schema) {
config.schema = agentFeature.credit_schema.map((s) => ({
metered_feature_id: s.metered_feature_id,
credit_amount: s.credit_cost,
}));
}
return {
internal_id: agentFeature.id,
org_id: "",
created_at: Date.now(),
env: AppEnv.Sandbox,
id: agentFeature.id,
name: agentFeature.name ?? agentFeature.display?.plural ?? agentFeature.id,
type: mapAgentTypeToFeatureType(agentFeature.type),
config: Object.keys(config).length > 0 ? config : null,
display: agentFeature.display ?? undefined,
archived: false,
event_names: [],
};
}
/** Convert AgentProductItem → ProductItem (shared DB type) */
export function agentItemToProductItem(item: AgentProductItem): ProductItem {
return {
feature_id: item.feature_id ?? undefined,
included_usage:
item.included_usage === "inf"
? Infinite
: (item.included_usage ?? undefined),
interval: item.interval as ProductItem["interval"],
price: item.price ?? undefined,
billing_units: item.billing_units ?? undefined,
usage_model: item.usage_model as ProductItem["usage_model"],
tiers: item.tiers?.map((t) => ({
to: t.to === "inf" ? Infinite : t.to,
amount: t.amount,
})),
};
}
/** Convert AgentProduct → ProductV2 (shared DB type) */
export function agentProductToProductV2(product: AgentProduct): ProductV2 {
return {
internal_id: product.id,
id: product.id,
name: product.name,
description: null,
is_add_on: product.is_add_on ?? false,
is_default: product.is_default ?? false,
version: 1,
group: product.group ?? null,
env: AppEnv.Sandbox,
free_trial: null, // Handled separately in preview transformations
items: (product.items ?? []).map(agentItemToProductItem),
created_at: Date.now(),
};
}
// ============ SHARED → AGENT CONVERTERS ============
function mapFeatureTypeToAgentType(feature: Feature): AgentFeatureType {
if (feature.type === FeatureType.CreditSystem) {
return "credit_system";
}
if (feature.type === FeatureType.Boolean) {
return "boolean";
}
if (feature.type === FeatureType.Metered) {
const usageType = feature.config?.usage_type;
if (
usageType === "continuous_use" ||
usageType === FeatureUsageType.Continuous
) {
return "continuous_use";
}
return "single_use";
}
return "static";
}
/** Convert Feature → AgentFeature */
export function featureToAgentFeature(feature: Feature): AgentFeature {
const agentFeature: AgentFeature = {
id: feature.id,
name: feature.name,
type: mapFeatureTypeToAgentType(feature),
};
if (feature.display?.singular || feature.display?.plural) {
agentFeature.display = {
singular: feature.display.singular ?? feature.name,
plural: feature.display.plural ?? feature.name,
};
}
if (feature.type === FeatureType.CreditSystem && feature.config?.schema) {
agentFeature.credit_schema = feature.config.schema.map(
(s: { metered_feature_id: string; credit_amount: number }) => ({
metered_feature_id: s.metered_feature_id,
credit_cost: s.credit_amount,
}),
);
}
return agentFeature;
}
/** Convert ProductItem → AgentProductItem */
export function productItemToAgentItem(item: ProductItem): AgentProductItem {
const agentItem: AgentProductItem = {};
if (item.feature_id) {
agentItem.feature_id = item.feature_id;
}
if (item.included_usage !== undefined && item.included_usage !== null) {
agentItem.included_usage =
item.included_usage === Infinite ? "inf" : item.included_usage;
}
if (item.interval) {
agentItem.interval = item.interval;
}
if (item.price !== undefined && item.price !== null) {
agentItem.price = item.price;
}
// Copy tiers if present (tiered pricing)
if (item.tiers && item.tiers.length > 0) {
agentItem.tiers = item.tiers.map((t) => ({
to: t.to === Infinite ? "inf" : t.to,
amount: t.amount,
}));
}
if (item.usage_model) {
agentItem.usage_model = item.usage_model as "prepaid" | "pay_per_use";
}
if (item.billing_units) {
agentItem.billing_units = item.billing_units;
}
return agentItem;
}
/** Convert ProductV2 → AgentProduct */
export function productV2ToAgentProduct(product: ProductV2): AgentProduct {
const agentProduct: AgentProduct = {
id: product.id,
name: product.name,
};
if (product.is_add_on) {
agentProduct.is_add_on = true;
}
if (product.is_default) {
agentProduct.is_default = true;
}
if (product.group) {
agentProduct.group = product.group;
}
if (product.items && product.items.length > 0) {
agentProduct.items = product.items.map(productItemToAgentItem);
}
if (product.free_trial) {
agentProduct.free_trial = {
length: product.free_trial.length,
duration: product.free_trial.duration as "day" | "month" | "year",
unique_fingerprint: product.free_trial.unique_fingerprint,
card_required: product.free_trial.card_required,
};
}
return agentProduct;
}
/** Convert ProductV2[] and Feature[] → AgentPricingConfig */
export function convertToAgentConfig({
products,
features,
}: {
products: ProductV2[];
features: Feature[];
}): AgentPricingConfig {
return {
features: features.map(featureToAgentFeature),
products: products.map(productV2ToAgentProduct),
};
}

View File

@@ -192,7 +192,11 @@ export const getFeaturePriceItemDisplay = ({
: "";
// Build price string (e.g., "$0.01")
const priceStr = formatTiers({ item, currency, amountFormatOptions }) ?? "";
const priceStr =
formatTiers({ item, currency, amountFormatOptions }) ??
(notNullish(item.price)
? formatAmount({ currency, amount: item.price, amountFormatOptions })
: "");
// Build billing unit string (e.g., "credit" or "100 credits")
const billingUnits = item.billing_units ?? 1;

View File

@@ -5,6 +5,7 @@ import { NuqsAdapter } from "nuqs/adapters/react-router/v7";
import { useEffect, useRef, useState } from "react";
import { Outlet, useNavigate } from "react-router";
import { CustomToaster } from "@/components/general/CustomToaster";
import { SandboxBanner } from "@/components/general/SandboxBanner";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { PortalContainerContext } from "@/contexts/PortalContainerContext";
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
@@ -13,11 +14,11 @@ import { useOrg } from "@/hooks/common/useOrg";
import { useDevQuery } from "@/hooks/queries/useDevQuery";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { useEventNames } from "@/views/customers/customer/analytics/hooks/useEventNames";
import { useSession } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { useEnv } from "@/utils/envUtils";
import CommandBar from "@/views/command-bar/CommandBar";
import { useEventNames } from "@/views/customers/customer/analytics/hooks/useEventNames";
import { useCusSearchQuery } from "@/views/customers/hooks/useCusSearchQuery";
import LoadingScreen from "@/views/general/LoadingScreen";
import { InviteNotifications } from "@/views/general/notifications/InviteNotifications";
@@ -57,7 +58,7 @@ export function MainLayout() {
}
}, [org, orgLoading, navigate]);
// 1. If not loaded, show loading screen
// Show loading screen while data is loading
if (isPending || orgLoading) {
return (
<AutumnProvider
@@ -68,13 +69,7 @@ export function MainLayout() {
<MainSidebar />
<div className="w-full h-screen flex flex-col overflow-hidden py-3 pr-3">
<div className="w-full h-full flex flex-col overflow-hidden rounded-lg border">
{env === AppEnv.Sandbox && (
<div className="w-full min-h-10 h-10 bg-t8/10 border-t8/20 border-b text-white text-sm flex items-center justify-center relative px-4">
<p className="font-medium text-t8 font-mono">
You&apos;re in sandbox
</p>
</div>
)}
{env === AppEnv.Sandbox && <SandboxBanner />}
<div className="flex bg-background flex-col h-full">
<LoadingScreen />
</div>
@@ -143,8 +138,7 @@ const MainContent = ({
className="w-full h-full flex flex-col overflow-hidden rounded-xl border relative"
>
{env === AppEnv.Sandbox && (
<div className="w-full min-h-10 h-10 bg-t8/10 text-sm flex items-center justify-center relative px-4 text-t8 border-b border-t8/20">
<p className="font-medium font-mono">You&apos;re in sandbox</p>
<SandboxBanner>
{!org?.deployed && (
<IconButton
variant="secondary"
@@ -152,12 +146,12 @@ const MainContent = ({
icon={<ArrowRightIcon />}
iconOrientation="right"
onClick={() => setShowDeployDialog(true)}
className="absolute right-3 border-t8/50 animate-in fade-in-0 duration-300 slide-in-from-right-2"
className="border-sandbox/50 animate-in fade-in-0 duration-300 slide-in-from-right-2"
>
Deploy to Production
</IconButton>
)}
</div>
</SandboxBanner>
)}
<DeployToProdDialog
open={showDeployDialog}

View File

@@ -0,0 +1,64 @@
import { CornerDownLeftIcon, Loader2Icon } from "lucide-react";
import type { FormEvent, KeyboardEvent } from "react";
import { InputGroupButton } from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
/**
* Compact inline prompt input with submit button on the same line.
* Designed for simple text input without attachments.
*/
export function CompactPromptInput({
value,
onChange,
onSubmit,
placeholder = "Describe changes...",
isLoading,
className,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
isLoading?: boolean;
className?: string;
}) {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (value.trim() && !isLoading) onSubmit();
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey && value.trim() && !isLoading) {
e.preventDefault();
onSubmit();
}
};
return (
<form onSubmit={handleSubmit} className={cn("w-full", className)}>
<div className="flex items-center gap-2 rounded-xl border bg-white dark:bg-card px-3 py-2 dark:border-white/15 shadow-lg">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isLoading}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-t4"
/>
<InputGroupButton
type="submit"
variant="primary"
size="icon-sm"
disabled={!value.trim() || isLoading}
>
{isLoading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<CornerDownLeftIcon className="size-4" />
)}
</InputGroupButton>
</div>
</form>
);
}

View File

@@ -45,7 +45,7 @@ export const MessageContent = ({
<div
className={cn(
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-interactive-secondary group-[.is-user]:border group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className,
)}

View File

@@ -1,4 +1,8 @@
import type { ProductItem } from "@autumn/shared";
import {
FreeTrialDuration,
type PlanTiming,
type ProductItem,
} from "@autumn/shared";
import { z } from "zod/v4";
export const AttachFormSchema = z.object({
@@ -6,6 +10,10 @@ export const AttachFormSchema = z.object({
prepaidOptions: z.record(z.string(), z.number().nonnegative()),
items: z.custom<ProductItem[]>().nullable(),
version: z.number().positive().optional(),
trialLength: z.number().positive().nullable(),
trialDuration: z.enum(FreeTrialDuration),
trialEnabled: z.boolean(),
planSchedule: z.custom<PlanTiming>().nullable(),
});
export type AttachForm = z.infer<typeof AttachFormSchema>;

View File

@@ -7,6 +7,7 @@ import {
} from "@/components/ui/popover";
import { Button } from "@/components/v2/buttons/Button";
import { SheetFooter } from "@/components/v2/sheets/SharedSheetComponents";
import { useOrg } from "@/hooks/common/useOrg";
import { useAttachFormContext } from "../context/AttachFormProvider";
const FOOTER_DELAY_MS = 350;
@@ -20,6 +21,9 @@ export function AttachFooter() {
formValues,
} = useAttachFormContext();
const { org } = useOrg();
const ownStripeAccount = org?.stripe_connection !== "default";
const hasProductSelected = !!formValues.productId;
const isLoading = previewQuery.isLoading;
const hasError = !!previewQuery.error;
@@ -47,7 +51,11 @@ export function AttachFooter() {
>
<Popover>
<PopoverTrigger asChild>
<Button variant="secondary" className="w-full" disabled={isPending}>
<Button
variant="secondary"
className="w-full"
disabled={isPending || !ownStripeAccount}
>
Send an Invoice
</Button>
</PopoverTrigger>

View File

@@ -1,140 +1,110 @@
import type { ProductItem } from "@autumn/shared";
import { buildEditsForItem, UsageModel } from "@autumn/shared";
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { LayoutGroup, motion } from "motion/react";
import { PriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay";
import { StatusBadge } from "@/components/forms/update-subscription-v2/components/StatusBadge";
import { SubscriptionItemRow } from "@/components/forms/update-subscription-v2/components/SubscriptionItemRow";
import { LAYOUT_TRANSITION } from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { Button } from "@/components/v2/buttons/Button";
import { motion } from "motion/react";
import { useMemo } from "react";
import { PlanItemsSection } from "@/components/forms/shared";
import {
STAGGER_CONTAINER,
STAGGER_ITEM,
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import { useOrg } from "@/hooks/common/useOrg";
import { useAttachFormContext } from "../context/AttachFormProvider";
function SectionTitle({ hasCustomizations }: { hasCustomizations: boolean }) {
return (
<div className="flex items-center gap-2">
<span>Plan Configuration</span>
{hasCustomizations && <StatusBadge variant="created">Custom</StatusBadge>}
</div>
);
}
import { outgoingToProductItems } from "../utils/attachDiffUtils";
import { AttachPlanSkeleton } from "./AttachPlanSkeleton";
import { AttachSectionTitle } from "./AttachSectionTitle";
export function AttachPlanSection() {
const {
form,
formValues,
originalItems,
features,
originalItems: productTemplateItems,
productWithFormItems: product,
hasCustomizations,
handleEditPlan,
previewQuery,
} = useAttachFormContext();
const { prepaidOptions } = formValues;
const { prepaidOptions, trialEnabled } = formValues;
const { org } = useOrg();
const currency = org?.default_currency ?? "USD";
const originalItemsMap = new Map(
originalItems?.filter((i) => i.feature_id).map((i) => [i.feature_id, i]) ??
[],
// Convert outgoing balances to ProductItem format for diff comparison
// This shows what the customer is losing (outgoing) vs gaining (incoming)
const outgoingItems = useMemo(
() => outgoingToProductItems(previewQuery.data?.outgoing),
[previewQuery.data?.outgoing],
);
const currentFeatureIds = new Set(
product?.items?.map((i) => i.feature_id).filter(Boolean) ?? [],
);
// Use outgoing items as the "original" for comparison when available
// This enables diffs like "100 → 200" for features in outgoing products
// Falls back to product template if no outgoing (new customer or no replacements)
const originalItemsForDiff =
outgoingItems.length > 0 ? outgoingItems : productTemplateItems;
const deletedItems =
hasCustomizations && originalItems
? originalItems.filter(
(i) => i.feature_id && !currentFeatureIds.has(i.feature_id),
)
: [];
// When there are outgoing items, always show diffs because we're comparing
// outgoing (what customer has) vs incoming (what they're getting) - different things
const showDiffs = hasCustomizations || outgoingItems.length > 0;
// Show skeleton only on initial load (isPending = no data yet)
// Subsequent fetches keep showing previous data via keepPreviousData
if (previewQuery.isPending) {
return <AttachPlanSkeleton />;
}
if (!product) return null;
const hasItems =
(product?.items?.length ?? 0) > 0 ||
(showDiffs &&
originalItemsForDiff?.some(
(i) =>
i.feature_id &&
!product?.items?.some((pi) => pi.feature_id === i.feature_id),
));
// Common props for PlanItemsSection
const planItemsProps = {
product,
originalItems: originalItemsForDiff,
features,
prepaidOptions,
initialPrepaidOptions: {},
form,
hasCustomizations: showDiffs,
currency,
onEditPlan: handleEditPlan,
gateDeletedItemsByCustomizations: true,
} as const;
return (
<SheetSection
title={<SectionTitle hasCustomizations={hasCustomizations} />}
withSeparator
>
{(product?.items?.length ?? 0) > 0 || deletedItems.length > 0 ? (
<>
<div className="flex gap-2 justify-between items-center mb-3">
<PriceDisplay product={product} currency={currency} />
</div>
<LayoutGroup>
<div className="space-y-2">
{product?.items?.map((item: ProductItem, index: number) => {
if (!item.feature_id) return null;
const featureId = item.feature_id;
const isPrepaid = item.usage_model === UsageModel.Prepaid;
const currentPrepaidQuantity = isPrepaid
? (prepaidOptions[featureId] ?? 0)
: undefined;
const originalItem = originalItemsMap.get(featureId);
const isCreated =
hasCustomizations &&
!originalItem &&
originalItems &&
originalItems.length > 0;
const edits = hasCustomizations
? buildEditsForItem({
updatedItem: item,
originalItem,
updatedPrepaidQuantity: currentPrepaidQuantity,
originalPrepaidQuantity: undefined,
})
: [];
return (
<motion.div
key={featureId || item.price_id || index}
layout
transition={LAYOUT_TRANSITION}
>
<SubscriptionItemRow
item={item}
edits={edits}
prepaidQuantity={currentPrepaidQuantity}
form={form}
featureId={featureId}
isCreated={isCreated}
/>
</motion.div>
);
})}
{deletedItems.map((item: ProductItem, index: number) => (
<motion.div
key={`deleted-${item.feature_id || index}`}
layout
transition={LAYOUT_TRANSITION}
>
<SubscriptionItemRow item={item} isDeleted />
</motion.div>
))}
<motion.div layout transition={LAYOUT_TRANSITION}>
<Button
variant="secondary"
onClick={handleEditPlan}
className="w-full"
>
<PencilSimpleIcon size={14} className="mr-1" />
Edit Plan Items
</Button>
</motion.div>
</div>
</LayoutGroup>
</>
) : (
<Button variant="secondary" onClick={handleEditPlan} className="w-full">
<PencilSimpleIcon size={14} className="mr-1" />
Edit Plan Items
</Button>
)}
<SheetSection withSeparator>
<motion.div
className="space-y-2"
initial="hidden"
animate="visible"
variants={STAGGER_CONTAINER}
>
<motion.div variants={STAGGER_ITEM}>
<h3 className="text-sub select-none w-full">
<AttachSectionTitle />
</h3>
</motion.div>
{hasItems ? (
<PlanItemsSection
{...planItemsProps}
trialConfig={{
trialEnabled,
onTrialCollapse: () => form.setFieldValue("trialEnabled", false),
}}
useStaggerAnimation
/>
) : (
<motion.div variants={STAGGER_ITEM}>
<PlanItemsSection {...planItemsProps} />
</motion.div>
)}
</motion.div>
</SheetSection>
);
}

View File

@@ -0,0 +1,83 @@
import { GearIcon, TimerIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import {
STAGGER_CONTAINER,
STAGGER_ITEM,
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { Skeleton } from "@/components/ui/skeleton";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
export function AttachPlanSkeleton() {
return (
<SheetSection withSeparator>
<motion.div
className="space-y-2"
initial="hidden"
animate="visible"
variants={STAGGER_CONTAINER}
>
{/* Section title - static content with disabled buttons */}
<motion.div variants={STAGGER_ITEM}>
<h3 className="text-sub select-none w-full">
<span className="flex items-center justify-between w-full gap-2">
<span className="flex items-center gap-1.5">
Plan Configuration
</span>
<span className="flex items-center gap-2">
<IconButton
icon={<GearIcon size={14} />}
variant="secondary"
className="h-7 whitespace-nowrap"
disabled
>
Settings
</IconButton>
<IconButton
icon={<TimerIcon size={14} />}
variant="secondary"
className="h-7 whitespace-nowrap"
disabled
>
Free Trial
</IconButton>
</span>
</span>
</h3>
</motion.div>
{/* Price display skeleton */}
<motion.div
variants={STAGGER_ITEM}
className="flex gap-2 justify-between items-center"
>
<span className="flex items-center gap-1">
<Skeleton className="h-5 w-10" />
<Skeleton className="h-4 w-16" />
</span>
</motion.div>
{/* Item rows skeleton */}
{[0, 1].map((i) => (
<motion.div key={`skeleton-row-${i}`} variants={STAGGER_ITEM}>
<div className="flex items-center flex-1 min-w-0 h-10 px-3 rounded-xl input-base">
<div className="flex flex-row items-center flex-1 gap-2 min-w-0">
<div className="flex flex-row items-center gap-1 shrink-0">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-1 w-1 rounded-full" />
<Skeleton className="h-4 w-4 rounded" />
</div>
<Skeleton className="h-4 w-32" />
</div>
</div>
</motion.div>
))}
{/* Edit button skeleton */}
<motion.div variants={STAGGER_ITEM}>
<Skeleton className="h-9 w-full rounded-lg" />
</motion.div>
</motion.div>
</SheetSection>
);
}

View File

@@ -1,6 +1,8 @@
import type { AxiosError } from "axios";
import { format } from "date-fns";
import { motion } from "motion/react";
import { PreviewErrorDisplay } from "@/components/forms/update-subscription-v2/components/PreviewErrorDisplay";
import { LAYOUT_TRANSITION } from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { LineItemsPreview } from "@/components/v2/LineItemsPreview";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import { getBackendErr } from "@/utils/genUtils";
@@ -41,20 +43,24 @@ export function AttachPreviewSection() {
if (error) {
return (
<SheetSection title="Pricing Preview" withSeparator>
<PreviewErrorDisplay error={error} />
</SheetSection>
<motion.div layout transition={LAYOUT_TRANSITION}>
<SheetSection title="Pricing Preview" withSeparator>
<PreviewErrorDisplay error={error} />
</SheetSection>
</motion.div>
);
}
return (
<LineItemsPreview
title="Pricing Preview"
isLoading={isLoading}
lineItems={previewData?.line_items}
currency={previewData?.currency}
totals={totals}
filterZeroAmounts
/>
<motion.div layout transition={LAYOUT_TRANSITION}>
<LineItemsPreview
title="Pricing Preview"
isLoading={isLoading}
lineItems={previewData?.line_items}
currency={previewData?.currency}
totals={totals}
filterZeroAmounts
/>
</motion.div>
);
}

View File

@@ -20,6 +20,7 @@ export function AttachProductSelection() {
{(field) => (
<field.SelectField
label=""
searchable
options={availableProducts.map((p) => ({
label: p.name,
value: p.id,
@@ -32,6 +33,8 @@ export function AttachProductSelection() {
: undefined,
}))}
placeholder="Select Product"
searchPlaceholder="Search plans..."
emptyText="No products found"
hideFieldInfo
selectValueAfter={
hasCustomizations && productId ? (

View File

@@ -0,0 +1,70 @@
import { InfoIcon, TimerIcon } from "@phosphor-icons/react";
import { IconButton } from "@/components/v2/buttons/IconButton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { cn } from "@/lib/utils";
import { useAttachFormContext } from "../context/AttachFormProvider";
import { AttachSettingsPopover } from "./AttachSettingsPopover";
export function AttachSectionTitle() {
const { hasCustomizations, form, formValues } = useAttachFormContext();
const { trialEnabled, trialLength } = formValues;
const hasTrialValue = trialLength !== null && trialLength > 0;
const trialIsActive = trialEnabled && hasTrialValue;
return (
<span className="flex items-center justify-between w-full gap-2">
<span className="flex items-center gap-1.5">
Plan Configuration
{hasCustomizations && (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
size={14}
weight="fill"
className="text-amber-500 cursor-help"
/>
</TooltipTrigger>
<TooltipContent side="top">
This plan's configuration has been customized. See changes below.
</TooltipContent>
</Tooltip>
)}
</span>
<span className="flex items-center gap-2">
<AttachSettingsPopover />
<Tooltip>
<TooltipTrigger asChild>
<IconButton
icon={
<TimerIcon
size={14}
weight={trialIsActive ? "fill" : "regular"}
/>
}
variant="secondary"
className={cn(
"h-7 whitespace-nowrap",
trialIsActive &&
"text-purple-400! border-purple-500/50 bg-purple-500/10",
trialEnabled && !trialIsActive && "border-primary",
)}
onClick={() => form.setFieldValue("trialEnabled", !trialEnabled)}
>
Free Trial
</IconButton>
</TooltipTrigger>
<TooltipContent side="top">
{trialIsActive
? "Trial configured - click to edit"
: "Add a free trial"}
</TooltipContent>
</Tooltip>
</span>
</span>
);
}

View File

@@ -0,0 +1,122 @@
import type { PlanTiming } from "@autumn/shared";
import { CalendarIcon, GearIcon, LightningIcon } from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox";
import { cn } from "@/lib/utils";
import { useAttachFormContext } from "../context/AttachFormProvider";
export function AttachSettingsPopover() {
const [open, setOpen] = useState(false);
const { form, formValues, previewQuery } = useAttachFormContext();
const { planSchedule } = formValues;
const previewData = previewQuery.data;
// Compute the default planSchedule based on upgrade vs downgrade
const defaultPlanSchedule = useMemo((): PlanTiming => {
if (!previewData) return "immediate";
const hasOutgoing = previewData.outgoing.length > 0;
if (!hasOutgoing) return "immediate";
// Compare prices to determine upgrade vs downgrade
const incomingPrice = previewData.incoming[0]?.plan.price?.amount ?? 0;
const outgoingPrice = previewData.outgoing[0]?.plan.price?.amount ?? 0;
const isUpgrade = incomingPrice > outgoingPrice;
return isUpgrade ? "immediate" : "end_of_cycle";
}, [previewData]);
// Effective value: user's choice or computed default
const effectivePlanSchedule = planSchedule ?? defaultPlanSchedule;
const handleScheduleChange = (value: PlanTiming) => {
form.setFieldValue("planSchedule", value);
};
const isImmediateSelected = effectivePlanSchedule === "immediate";
const isEndOfCycleSelected = effectivePlanSchedule === "end_of_cycle";
// Show blue highlight when user has overridden the default
const hasCustomSchedule =
planSchedule !== null && planSchedule !== defaultPlanSchedule;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<IconButton
icon={
<GearIcon
size={14}
weight={hasCustomSchedule ? "fill" : "regular"}
/>
}
variant="secondary"
className={cn(
"h-7 whitespace-nowrap",
hasCustomSchedule &&
"text-blue-400! border-blue-500/50 bg-blue-500/10",
)}
>
Settings
</IconButton>
</PopoverTrigger>
<PopoverContent
align="end"
className="p-3 w-[380px] z-101 bg-muted"
sideOffset={4}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-3">
<div className="flex flex-col gap-1">
<p className="text-t2 font-medium text-base">
Advanced Configuration
</p>
<p className="text-t3 text-xs">Override default billing behavior</p>
</div>
<Separator />
<div className="flex items-center justify-between gap-3">
<span className="text-t1 text-sm">Plan Schedule</span>
<div className="flex">
<IconCheckbox
icon={<LightningIcon />}
iconOrientation="left"
variant="secondary"
size="sm"
checked={isImmediateSelected}
onCheckedChange={() => handleScheduleChange("immediate")}
className={cn(
"rounded-r-none",
!isImmediateSelected && "border-r-0",
)}
>
Immediately
</IconCheckbox>
<IconCheckbox
icon={<CalendarIcon />}
iconOrientation="left"
variant="secondary"
size="sm"
checked={isEndOfCycleSelected}
onCheckedChange={() => handleScheduleChange("end_of_cycle")}
className={cn(
"rounded-l-none",
!isEndOfCycleSelected && "border-l-0",
)}
>
End of cycle
</IconCheckbox>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,93 @@
import { MinusCircleIcon, PlusCircleIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import {
STAGGER_CONTAINER,
STAGGER_ITEM,
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { Skeleton } from "@/components/ui/skeleton";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
import { useAttachFormContext } from "../context/AttachFormProvider";
function AttachUpdatesSkeleton() {
return (
<SheetSection withSeparator>
<motion.div
initial="hidden"
animate="visible"
variants={STAGGER_CONTAINER}
>
<motion.div variants={STAGGER_ITEM}>
<div className="flex items-center gap-2 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Skeleton className="h-4 w-4 rounded-full shrink-0" />
<Skeleton className="h-4 w-48" />
</div>
</motion.div>
</motion.div>
</SheetSection>
);
}
export function AttachUpdatesSection() {
const { previewQuery, formValues, product } = useAttachFormContext();
const hasProductSelected = !!formValues.productId;
const { data: previewData, isPending } = previewQuery;
const outgoing = previewData?.outgoing ?? [];
if (!hasProductSelected) {
return null;
}
if (isPending) {
return <AttachUpdatesSkeleton />;
}
if (!product) {
return null;
}
const renderOutgoingPlans = () => {
return outgoing.map((change, index) => {
const isLast = index === outgoing.length - 1;
const needsComma = index > 0 && !isLast;
const needsAnd = isLast && index > 0;
return (
<span key={change.plan.id}>
{needsComma && ", "}
{needsAnd && " and "}
<MinusCircleIcon
weight="fill"
className="text-red-500 size-3.5 inline align-[-2px] mr-1"
/>
<span className="text-foreground font-medium">
{change.plan.name}
</span>
</span>
);
});
};
return (
<SheetSection withSeparator>
<motion.div
initial="hidden"
animate="visible"
variants={STAGGER_CONTAINER}
>
<motion.div variants={STAGGER_ITEM}>
<InfoBox variant="note">
Attaching{" "}
<PlusCircleIcon
weight="fill"
className="text-green-500 size-3.5 inline align-[-2px] mr-1"
/>
<span className="text-foreground font-medium">{product.name}</span>
{outgoing.length > 0 && <> and removing {renderOutgoingPlans()}</>}
</InfoBox>
</motion.div>
</motion.div>
</SheetSection>
);
}

View File

@@ -29,13 +29,7 @@ import {
} from "../hooks/useAttachPreview";
import { useAttachRequestBody } from "../hooks/useAttachRequestBody";
export interface AttachFormContext {
customerId: string | undefined;
entityId: string | undefined;
}
interface AttachFormContextValue {
formContext: AttachFormContext;
form: UseAttachForm;
formValues: AttachForm;
features: Feature[];
@@ -93,7 +87,16 @@ export function AttachFormProvider({
const { products } = useProductsQuery();
const formValues = useStore(form.store, (state) => state.values);
const { productId, prepaidOptions, items, version } = formValues;
const {
productId,
prepaidOptions,
items,
version,
trialLength,
trialDuration,
trialEnabled,
planSchedule,
} = formValues;
const product = useMemo(
() => products.find((p) => p.id === productId && !p.archived),
@@ -155,23 +158,20 @@ export function AttachFormProvider({
return baseFrontendProduct;
}, [product, items]);
const previewQuery = useAttachPreview({
const { requestBody, buildRequestBody } = useAttachRequestBody({
customerId,
entityId,
product,
prepaidOptions,
items,
version,
trialLength,
trialDuration,
trialEnabled,
planSchedule,
});
const { buildRequestBody } = useAttachRequestBody({
customerId,
entityId,
product,
prepaidOptions,
items,
version,
});
const previewQuery = useAttachPreview({ requestBody });
const { handleConfirm, handleInvoiceAttach, isPending } = useAttachMutation({
customerId,
@@ -221,17 +221,8 @@ export function AttachFormProvider({
onPlanEditorClose?.();
}, [onPlanEditorClose]);
const formContext = useMemo(
(): AttachFormContext => ({
customerId,
entityId,
}),
[customerId, entityId],
);
const value = useMemo<AttachFormContextValue>(
() => ({
formContext,
form,
formValues,
features,
@@ -250,7 +241,6 @@ export function AttachFormProvider({
handleInvoiceAttach,
}),
[
formContext,
form,
formValues,
features,

View File

@@ -1,3 +1,4 @@
import { FreeTrialDuration } from "@autumn/shared";
import { useAppForm } from "@/hooks/form/form";
import { type AttachForm, AttachFormSchema } from "../attachFormSchema";
@@ -14,6 +15,10 @@ export function useAttachForm({
prepaidOptions: initialPrepaidOptions ?? {},
items: null,
version: undefined,
trialLength: null,
trialDuration: FreeTrialDuration.Day,
trialEnabled: false,
planSchedule: null,
} as AttachForm,
validators: {
onChange: AttachFormSchema,

View File

@@ -1,16 +1,9 @@
import type { AttachParamsV0 } from "@autumn/shared";
import type { AttachParamsV0, BillingResponse } from "@autumn/shared";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { toast } from "sonner";
import { useAxiosInstance } from "@/services/useAxiosInstance";
interface AttachResponse {
checkout_url?: string;
invoice?: {
stripe_id: string;
};
}
export function useAttachMutation({
customerId,
buildRequestBody,
@@ -51,7 +44,7 @@ export function useAttachMutation({
throw new Error("Failed to build request body");
}
const response = await axiosInstance.post<AttachResponse>(
const response = await axiosInstance.post<BillingResponse>(
"/v1/billing/attach",
requestBody,
);
@@ -59,15 +52,14 @@ export function useAttachMutation({
return { data: response.data, useInvoice };
},
onSuccess: ({ data, useInvoice }) => {
if (data?.checkout_url) {
onCheckoutRedirect?.(data.checkout_url);
toast.success("Redirecting to checkout...");
return;
}
if (useInvoice && data?.invoice) {
onInvoiceCreated?.(data.invoice.stripe_id);
toast.success("Invoice created successfully");
if (useInvoice) {
if (data?.invoice) {
onInvoiceCreated?.(data.invoice.stripe_id);
toast.success("Invoice created successfully");
}
} else if (data?.payment_url) {
onCheckoutRedirect?.(data.payment_url);
toast.success("Redirecting to complete payment...");
} else {
toast.success("Product attached successfully");
}

View File

@@ -1,46 +1,21 @@
import type {
BillingPreviewResponse,
ProductItem,
ProductV2,
} from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import type { AttachParamsV0, AttachPreviewResponse } from "@autumn/shared";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { useEffect, useMemo, useState } from "react";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useAttachRequestBody } from "./useAttachRequestBody";
interface UseAttachPreviewParams {
customerId: string | undefined;
entityId: string | undefined;
product: ProductV2 | undefined;
prepaidOptions: Record<string, number>;
items: ProductItem[] | null;
version: number | undefined;
requestBody: AttachParamsV0 | null;
enabled?: boolean;
}
export function useAttachPreview({
customerId,
entityId,
product,
prepaidOptions,
items,
version,
requestBody,
enabled,
}: UseAttachPreviewParams) {
const axiosInstance = useAxiosInstance();
const { requestBody } = useAttachRequestBody({
customerId,
entityId,
product,
prepaidOptions,
items,
version,
});
const shouldEnable =
enabled !== undefined ? enabled : !!(customerId && product && requestBody);
const shouldEnable = enabled !== undefined ? enabled : !!requestBody;
const queryKeyDeps = useMemo(
() => JSON.stringify(requestBody),
@@ -61,11 +36,11 @@ export function useAttachPreview({
const query = useQuery({
queryKey: ["attach-preview-v2", debouncedQueryKey],
queryFn: async () => {
if (!requestBody || !customerId) {
if (!requestBody) {
return null;
}
const response = await axiosInstance.post<BillingPreviewResponse>(
const response = await axiosInstance.post<AttachPreviewResponse>(
"/v1/billing/preview_attach",
requestBody,
);
@@ -74,6 +49,7 @@ export function useAttachPreview({
},
enabled: shouldEnable,
staleTime: 0,
placeholderData: keepPreviousData,
retry: (failureCount, error) => {
const status = (error as AxiosError)?.response?.status;
if (status && status >= 400 && status < 500) return false;

View File

@@ -2,6 +2,8 @@ import {
type AttachParamsV0,
type AttachParamsV0Input,
type FeatureOptions,
type FreeTrialDuration,
type PlanTiming,
type ProductItem,
ProductItemInterval,
type ProductV2,
@@ -9,6 +11,7 @@ import {
} from "@autumn/shared";
import Decimal from "decimal.js";
import { useMemo } from "react";
import { getFreeTrial } from "@/components/forms/update-subscription-v2/utils/getFreeTrial";
interface UseAttachRequestBodyParams {
customerId: string | undefined;
@@ -17,6 +20,10 @@ interface UseAttachRequestBodyParams {
prepaidOptions: Record<string, number>;
items: ProductItem[] | null;
version: number | undefined;
trialLength: number | null;
trialDuration: FreeTrialDuration;
trialEnabled: boolean;
planSchedule: PlanTiming | null;
}
function convertPrepaidOptionsToFeatureOptions({
@@ -64,6 +71,10 @@ export function useAttachRequestBody({
prepaidOptions,
items,
version,
trialLength,
trialDuration,
trialEnabled,
planSchedule,
}: UseAttachRequestBodyParams) {
const requestBody = useMemo((): AttachParamsV0 | null => {
if (!customerId || !product) {
@@ -100,8 +111,33 @@ export function useAttachRequestBody({
body.version = version;
}
const freeTrial = getFreeTrial({
removeTrial: false,
trialLength,
trialDuration,
trialEnabled,
});
if (freeTrial !== undefined) {
body.free_trial = freeTrial;
}
if (planSchedule) {
body.plan_schedule = planSchedule;
}
return body;
}, [customerId, entityId, product, prepaidOptions, items, version]);
}, [
customerId,
entityId,
product,
prepaidOptions,
items,
version,
trialLength,
trialDuration,
trialEnabled,
planSchedule,
]);
const buildRequestBody = useMemo(
() =>

View File

@@ -6,10 +6,17 @@ export * from "./components/AttachFooter";
export * from "./components/AttachPlanSection";
export * from "./components/AttachPreviewSection";
export * from "./components/AttachProductSelection";
export * from "./components/AttachSectionTitle";
export * from "./components/AttachUpdatesSection";
// Context & Provider
export * from "./context/AttachFormProvider";
// Hooks
export * from "./hooks/useAttachForm";
export * from "./hooks/useAttachMutation";
export * from "./hooks/useAttachPreview";
export * from "./hooks/useAttachRequestBody";
// Utils
export * from "./utils/attachDiffUtils";

View File

@@ -0,0 +1,44 @@
import type { CheckoutChange, ProductItem } from "@autumn/shared";
/**
* Converts outgoing checkout changes to ProductItem format for diff comparison.
* Aggregates balances by feature_id (sums if same feature appears in multiple outgoing products).
*/
export function outgoingToProductItems(
outgoing: CheckoutChange[] | undefined,
): ProductItem[] {
if (!outgoing || outgoing.length === 0) return [];
// Aggregate balances by feature_id
const featureBalances = new Map<
string,
{ balance: number; unlimited: boolean }
>();
for (const change of outgoing) {
for (const [featureId, apiBalance] of Object.entries(change.balances)) {
const existing = featureBalances.get(featureId);
if (existing) {
// Sum balances from multiple outgoing products
existing.balance += apiBalance.granted_balance;
if (apiBalance.unlimited) {
existing.unlimited = true;
}
} else {
featureBalances.set(featureId, {
balance: apiBalance.granted_balance,
unlimited: apiBalance.unlimited,
});
}
}
}
// Convert to ProductItem format
return Array.from(featureBalances.entries()).map(
([featureId, data]): ProductItem => ({
feature_id: featureId,
included_usage: data.unlimited ? "inf" : data.balance,
}),
);
}

View File

@@ -1,4 +1,4 @@
import { CusProductStatus } from "@autumn/shared";
import { CusProductStatus, cp } from "@autumn/shared";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { useUpdateSubscriptionFormContext } from "@/components/forms/update-subscription-v2";
@@ -14,6 +14,8 @@ export function CancelFooter() {
const isScheduled = customerProduct.status === CusProductStatus.Scheduled;
const isDefault = customerProduct.product.is_default;
const { valid: isFreeOrOneOff } = cp(customerProduct).free().or.oneOff();
const isFreeDefault = isDefault && isFreeOrOneOff;
const isLoading = previewQuery.isLoading;
const hasError = !!previewQuery.error;
@@ -33,7 +35,7 @@ export function CancelFooter() {
const buttonLabel = isScheduled
? "Cancel Scheduled Plan"
: isDefault
: isFreeDefault
? "Cancel Default Plan"
: "Cancel Subscription";

View File

@@ -1,4 +1,4 @@
import { CusProductStatus } from "@autumn/shared";
import { CusProductStatus, cp } from "@autumn/shared";
import { CalendarIcon, LightningIcon } from "@phosphor-icons/react";
import { useUpdateSubscriptionFormContext } from "@/components/forms/update-subscription-v2";
import { PanelButton } from "@/components/v2/buttons/PanelButton";
@@ -14,7 +14,11 @@ export function CancelModeSection() {
customerProduct.subscription_ids &&
customerProduct.subscription_ids.length > 0;
const canChooseCancelMode = !isScheduled && !isDefault && !!hasSubscription;
const { valid: isFreeOrOneOff } = cp(customerProduct).free().or.oneOff();
const isFreeDefault = isDefault && isFreeOrOneOff;
const canChooseCancelMode =
!isScheduled && !isFreeDefault && !!hasSubscription;
if (!canChooseCancelMode) return null;

View File

@@ -0,0 +1,352 @@
import type {
Feature,
FeatureOptions,
FrontendProduct,
ProductItem,
} from "@autumn/shared";
import {
buildEditsForItem,
featureToOptions,
UsageModel,
} from "@autumn/shared";
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import type { UseAttachForm } from "@/components/forms/attach-v2/hooks/useAttachForm";
import { PriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay";
import { SubscriptionItemRow } from "@/components/forms/update-subscription-v2/components/SubscriptionItemRow";
import { TrialEditorRow } from "@/components/forms/update-subscription-v2/components/TrialEditorRow";
import { VersionChangeRow } from "@/components/forms/update-subscription-v2/components/VersionChangeRow";
import {
FAST_TRANSITION,
LAYOUT_TRANSITION,
STAGGER_CONTAINER,
STAGGER_ITEM,
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
import type { UseTrialStateReturn } from "@/components/forms/update-subscription-v2/hooks/useTrialState";
import type { UseUpdateSubscriptionForm } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm";
import { Button } from "@/components/v2/buttons/Button";
interface PriceChange {
oldPrice: string;
newPrice: string;
oldIntervalText: string | null;
newIntervalText: string | null;
isUpgrade: boolean;
}
interface VersionChange {
currentVersion: number;
selectedVersion: number;
}
interface TrialConfigSimple {
trialEnabled: boolean;
onTrialCollapse: () => void;
}
interface TrialConfigComplex {
trialState: UseTrialStateReturn;
}
type TrialConfig = TrialConfigSimple | TrialConfigComplex;
function isComplexTrialConfig(
config: TrialConfig,
): config is TrialConfigComplex {
return "trialState" in config;
}
export interface PlanItemsSectionProps {
product: FrontendProduct | undefined;
originalItems: ProductItem[] | undefined;
features: Feature[];
prepaidOptions: Record<string, number>;
initialPrepaidOptions: Record<string, number>;
existingOptions?: FeatureOptions[];
form: UseUpdateSubscriptionForm | UseAttachForm;
hasCustomizations: boolean;
currency: string;
onEditPlan: () => void;
priceChange?: PriceChange | null;
versionChange?: VersionChange | null;
trialConfig?: TrialConfig;
useStaggerAnimation?: boolean;
gateDeletedItemsByCustomizations?: boolean;
}
export function PlanItemsSection({
product,
originalItems,
features,
prepaidOptions,
initialPrepaidOptions,
existingOptions,
form,
hasCustomizations,
currency,
onEditPlan,
priceChange,
versionChange,
trialConfig,
useStaggerAnimation = false,
gateDeletedItemsByCustomizations = false,
}: PlanItemsSectionProps) {
const originalItemsMap = new Map(
originalItems?.filter((i) => i.feature_id).map((i) => [i.feature_id, i]) ??
[],
);
const currentFeatureIds = new Set(
product?.items?.map((i) => i.feature_id).filter(Boolean) ?? [],
);
const deletedItems = gateDeletedItemsByCustomizations
? hasCustomizations && originalItems
? originalItems.filter(
(i) => i.feature_id && !currentFeatureIds.has(i.feature_id),
)
: []
: (originalItems?.filter(
(i) => i.feature_id && !currentFeatureIds.has(i.feature_id),
) ?? []);
const hasItems = (product?.items?.length ?? 0) > 0 || deletedItems.length > 0;
const showTrialEditor = trialConfig
? isComplexTrialConfig(trialConfig)
? trialConfig.trialState.isTrialExpanded ||
trialConfig.trialState.removeTrial
: trialConfig.trialEnabled
: false;
const showVersionChange =
versionChange &&
versionChange.selectedVersion !== versionChange.currentVersion;
if (!hasItems) {
return (
<Button variant="secondary" onClick={onEditPlan} className="w-full">
<PencilSimpleIcon size={14} className="mr-1" />
Edit Plan Items
</Button>
);
}
const renderPriceDisplay = () => {
if (priceChange) {
return (
<span className="flex items-center gap-1.5">
<span className="text-t3">
{priceChange.oldPrice}
{priceChange.oldIntervalText && ` ${priceChange.oldIntervalText}`}
</span>
<span className="text-t4">-&gt;</span>
<span className="font-semibold text-t1">{priceChange.newPrice}</span>
<span className="text-t3">{priceChange.newIntervalText}</span>
</span>
);
}
return <PriceDisplay product={product} currency={currency} />;
};
const renderItemRow = (item: ProductItem, index: number) => {
if (!item.feature_id) return null;
const featureId = item.feature_id;
const isPrepaid = item.usage_model === UsageModel.Prepaid;
let currentPrepaidQuantity: number | undefined;
if (isPrepaid) {
currentPrepaidQuantity = prepaidOptions[featureId];
} else if (existingOptions) {
const featureForOptions = features?.find((f) => f.id === featureId);
const prepaidOption = featureToOptions({
feature: featureForOptions,
options: existingOptions,
});
currentPrepaidQuantity = prepaidOption?.quantity;
}
const initialPrepaidQuantity = isPrepaid
? initialPrepaidOptions[featureId]
: undefined;
const originalItem = originalItemsMap.get(featureId);
// Feature is "created" if it doesn't exist in originalItems
// For attach: originalItems comes from outgoing products (what's being replaced)
// For update: originalItems comes from current subscription
const isCreated =
!originalItem && originalItems && originalItems.length > 0;
const edits = hasCustomizations
? buildEditsForItem({
updatedItem: item,
originalItem,
updatedPrepaidQuantity: currentPrepaidQuantity,
originalPrepaidQuantity: initialPrepaidQuantity,
})
: [];
return (
<motion.div
key={featureId || item.price_id || index}
layout
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
transition={LAYOUT_TRANSITION}
>
<SubscriptionItemRow
item={item}
edits={edits}
prepaidQuantity={currentPrepaidQuantity}
form={form}
featureId={featureId}
isCreated={isCreated}
/>
</motion.div>
);
};
const renderDeletedItemRow = (item: ProductItem, index: number) => (
<motion.div
key={`deleted-${item.feature_id || index}`}
layout
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
transition={LAYOUT_TRANSITION}
>
<SubscriptionItemRow item={item} isDeleted />
</motion.div>
);
const renderVersionChangeRow = () => {
if (!showVersionChange || !versionChange) return null;
return (
<motion.div
key="version-change"
layout
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
transition={LAYOUT_TRANSITION}
>
<VersionChangeRow
currentVersion={versionChange.currentVersion}
selectedVersion={versionChange.selectedVersion}
/>
</motion.div>
);
};
const renderTrialEditor = () => {
if (!trialConfig || !showTrialEditor) return null;
if (isComplexTrialConfig(trialConfig)) {
const { trialState } = trialConfig;
return (
<motion.div
key="trial-editor"
layout
transition={LAYOUT_TRANSITION}
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
>
<TrialEditorRow
form={form}
isCurrentlyTrialing={trialState.isCurrentlyTrialing}
initialTrialLength={trialState.remainingTrialDays}
initialTrialFormatted={trialState.remainingTrialFormatted}
removeTrial={trialState.removeTrial}
onEndTrial={trialState.handleEndTrial}
onCollapse={() => trialState.setIsTrialExpanded(false)}
onRevert={trialState.handleRevertTrial}
/>
</motion.div>
);
}
return (
<AnimatePresence mode="popLayout">
{trialConfig.trialEnabled && (
<motion.div
key="trial-editor"
layout
initial={{ opacity: 0, y: 8 }}
animate={{
opacity: 1,
y: 0,
transition: { ...FAST_TRANSITION, delay: 0.15 },
}}
exit={{
opacity: 0,
y: -8,
transition: FAST_TRANSITION,
}}
transition={LAYOUT_TRANSITION}
>
<TrialEditorRow
form={form}
onCollapse={trialConfig.onTrialCollapse}
/>
</motion.div>
)}
</AnimatePresence>
);
};
const renderEditButton = () => (
<motion.div
layout
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
transition={LAYOUT_TRANSITION}
>
<Button variant="secondary" onClick={onEditPlan} className="w-full">
<PencilSimpleIcon size={14} className="mr-1" />
Edit Plan Items
</Button>
</motion.div>
);
if (useStaggerAnimation) {
return (
<LayoutGroup>
<motion.div
className="space-y-2"
initial="hidden"
animate="visible"
variants={STAGGER_CONTAINER}
>
<motion.div
variants={STAGGER_ITEM}
className="flex gap-2 justify-between items-center"
>
{renderPriceDisplay()}
</motion.div>
{product?.items?.map(renderItemRow)}
{deletedItems.map(renderDeletedItemRow)}
{renderTrialEditor()}
{renderEditButton()}
</motion.div>
</LayoutGroup>
);
}
return (
<>
<div className="flex gap-2 justify-between items-center mb-3">
{renderPriceDisplay()}
</div>
<LayoutGroup>
<div className="space-y-2">
{product?.items?.map(renderItemRow)}
{deletedItems.map(renderDeletedItemRow)}
{renderVersionChangeRow()}
{renderTrialEditor()}
{renderEditButton()}
</div>
</LayoutGroup>
</>
);
}

View File

@@ -0,0 +1,5 @@
// Shared form components
// This folder contains components that are shared between multiple form flows
// (e.g., update-subscription-v2 and attach-v2)
export * from "./PlanItemsSection";

Some files were not shown because too many files have changed in this diff Show More