Merge branch 'feat/attach-v2' of https://github.com/useautumn/autumn into feat/attach-v2
This commit is contained in:
24
.github/workflows/server-typecheck.yml
vendored
Normal file
24
.github/workflows/server-typecheck.yml
vendored
Normal 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
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
cd server && bun ts
|
||||
3
bun.lock
3
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
23
server/src/external/autumn/autumnCli.ts
vendored
23
server/src/external/autumn/autumnCli.ts
vendored
@@ -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 = {
|
||||
|
||||
7
server/src/external/tinybird/initTinybird.ts
vendored
7
server/src/external/tinybird/initTinybird.ts
vendored
@@ -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";
|
||||
|
||||
21
server/src/external/tinybird/pipes/index.ts
vendored
21
server/src/external/tinybird/pipes/index.ts
vendored
@@ -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";
|
||||
|
||||
40
server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts
vendored
Normal file
40
server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts
vendored
Normal 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,
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
179
server/src/external/upstash/rateLimitUtils.ts
vendored
179
server/src/external/upstash/rateLimitUtils.ts
vendored
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -19,6 +19,7 @@ export const idempotencyMiddleware = async (
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
idempotencyKey,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
54
server/src/honoMiddlewares/refreshProductsCacheMiddleware.ts
Normal file
54
server/src/honoMiddlewares/refreshProductsCacheMiddleware.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
150
server/src/internal/analytics/actions/_legacyListRawEvents.ts
Normal file
150
server/src/internal/analytics/actions/_legacyListRawEvents.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
93
server/src/internal/analytics/actions/listEvents.ts
Normal file
93
server/src/internal/analytics/actions/listEvents.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
262
server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts
Normal file
262
server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts
Normal 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>;
|
||||
176
server/src/internal/misc/rateLimiter/rateLimitConfigs.ts
Normal file
176
server/src/internal/misc/rateLimiter/rateLimitConfigs.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
104
server/src/internal/misc/rateLimiter/rateLimitFactory.ts
Normal file
104
server/src/internal/misc/rateLimiter/rateLimitFactory.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
110
server/src/internal/products/productCacheUtils.ts
Normal file
110
server/src/internal/products/productCacheUtils.ts
Normal 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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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 >
|
||||
|
||||
@@ -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.
|
||||
|
||||
36
server/tinybird/pipes/list_events_paginated.pipe
Normal file
36
server/tinybird/pipes/list_events_paginated.pipe
Normal 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) }}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
13
shared/api/billing/common/attachPreviewResponse.ts
Normal file
13
shared/api/billing/common/attachPreviewResponse.ts
Normal 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>;
|
||||
11
shared/api/common/epochUtils.ts
Normal file
11
shared/api/common/epochUtils.ts
Normal 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}`;
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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";
|
||||
|
||||
34
shared/models/billingModels/cusProductActions.ts
Normal file
34
shared/models/billingModels/cusProductActions.ts
Normal 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),
|
||||
});
|
||||
@@ -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
311
shared/utils/agentTypes.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'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'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}
|
||||
|
||||
64
vite/src/components/ai-elements/CompactPromptInput.tsx
Normal file
64
vite/src/components/ai-elements/CompactPromptInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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";
|
||||
|
||||
44
vite/src/components/forms/attach-v2/utils/attachDiffUtils.ts
Normal file
44
vite/src/components/forms/attach-v2/utils/attachDiffUtils.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
352
vite/src/components/forms/shared/PlanItemsSection.tsx
Normal file
352
vite/src/components/forms/shared/PlanItemsSection.tsx
Normal 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">-></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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
vite/src/components/forms/shared/index.ts
Normal file
5
vite/src/components/forms/shared/index.ts
Normal 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
Reference in New Issue
Block a user