diff --git a/.github/workflows/server-typecheck.yml b/.github/workflows/server-typecheck.yml new file mode 100644 index 000000000..1ea3e7c4f --- /dev/null +++ b/.github/workflows/server-typecheck.yml @@ -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 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 000000000..d71478d5d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +cd server && bun ts diff --git a/bun.lock b/bun.lock index d3ab49889..4936fc9f0 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index 5c3a7c499..e0135da54 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 25a62f089..154250789 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -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 = { diff --git a/server/src/external/tinybird/initTinybird.ts b/server/src/external/tinybird/initTinybird.ts index 5869a2538..a46054908 100644 --- a/server/src/external/tinybird/initTinybird.ts +++ b/server/src/external/tinybird/initTinybird.ts @@ -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"; diff --git a/server/src/external/tinybird/pipes/index.ts b/server/src/external/tinybird/pipes/index.ts index 05474b4e4..4b55c7fc1 100644 --- a/server/src/external/tinybird/pipes/index.ts +++ b/server/src/external/tinybird/pipes/index.ts @@ -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"; diff --git a/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts b/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts new file mode 100644 index 000000000..01fc651cb --- /dev/null +++ b/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts @@ -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, + }); diff --git a/server/src/external/tinybird/pipes/listEventsPipe.ts b/server/src/external/tinybird/pipes/listEventsPipe.ts index 3b3277870..ef1218071 100644 --- a/server/src/external/tinybird/pipes/listEventsPipe.ts +++ b/server/src/external/tinybird/pipes/listEventsPipe.ts @@ -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; -/** 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; -/** Creates the list_events pipe caller */ +/** Creates the legacy list_events pipe caller */ export const createListEventsPipe = (tb: Tinybird) => tb.buildPipe({ pipe: "list_events", diff --git a/server/src/external/upstash/rateLimitConstants.ts b/server/src/external/upstash/rateLimitConstants.ts deleted file mode 100644 index 56ea4d4bf..000000000 --- a/server/src/external/upstash/rateLimitConstants.ts +++ /dev/null @@ -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", -} diff --git a/server/src/external/upstash/rateLimitUtils.ts b/server/src/external/upstash/rateLimitUtils.ts deleted file mode 100644 index f2fa52107..000000000 --- a/server/src/external/upstash/rateLimitUtils.ts +++ /dev/null @@ -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) => { - 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; - 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, - }; - } -}; diff --git a/server/src/honoMiddlewares/idempotencyMiddleware.ts b/server/src/honoMiddlewares/idempotencyMiddleware.ts index baa9cbf54..512a8ff5e 100644 --- a/server/src/honoMiddlewares/idempotencyMiddleware.ts +++ b/server/src/honoMiddlewares/idempotencyMiddleware.ts @@ -19,6 +19,7 @@ export const idempotencyMiddleware = async ( orgId: ctx.org.id, env: ctx.env, idempotencyKey, + logger: ctx.logger, }); } diff --git a/server/src/honoMiddlewares/rateLimitMiddleware.ts b/server/src/honoMiddlewares/rateLimitMiddleware.ts index 81c660e70..0cbb4bcb9 100644 --- a/server/src/honoMiddlewares/rateLimitMiddleware.ts +++ b/server/src/honoMiddlewares/rateLimitMiddleware.ts @@ -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, next: Next) => { const ctx = c.get("ctx"); diff --git a/server/src/honoMiddlewares/refreshProductsCacheMiddleware.ts b/server/src/honoMiddlewares/refreshProductsCacheMiddleware.ts new file mode 100644 index 000000000..8fa376f79 --- /dev/null +++ b/server/src/honoMiddlewares/refreshProductsCacheMiddleware.ts @@ -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, + 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, + }); +}; diff --git a/server/src/internal/analytics/actions/_legacyListRawEvents.ts b/server/src/internal/analytics/actions/_legacyListRawEvents.ts new file mode 100644 index 000000000..6d75a2195 --- /dev/null +++ b/server/src/internal/analytics/actions/_legacyListRawEvents.ts @@ -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> => { + 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, + }; +}; diff --git a/server/src/internal/analytics/actions/aggregate.ts b/server/src/internal/analytics/actions/aggregate.ts index 7ec5eb9d3..a198f56db 100644 --- a/server/src/internal/analytics/actions/aggregate.ts +++ b/server/src/internal/analytics/actions/aggregate.ts @@ -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, diff --git a/server/src/internal/analytics/actions/index.ts b/server/src/internal/analytics/actions/eventActions.ts similarity index 53% rename from server/src/internal/analytics/actions/index.ts rename to server/src/internal/analytics/actions/eventActions.ts index 883421a88..cbc2c3d59 100644 --- a/server/src/internal/analytics/actions/index.ts +++ b/server/src/internal/analytics/actions/eventActions.ts @@ -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; diff --git a/server/src/internal/analytics/actions/eventValidationUtils.ts b/server/src/internal/analytics/actions/eventValidationUtils.ts new file mode 100644 index 000000000..959861885 --- /dev/null +++ b/server/src/internal/analytics/actions/eventValidationUtils.ts @@ -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, + }); + } + } +}; diff --git a/server/src/internal/analytics/actions/listEvents.ts b/server/src/internal/analytics/actions/listEvents.ts new file mode 100644 index 000000000..4aef9abba --- /dev/null +++ b/server/src/internal/analytics/actions/listEvents.ts @@ -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, + }; +}; diff --git a/server/src/internal/analytics/actions/listRawEvents.ts b/server/src/internal/analytics/actions/listRawEvents.ts index 099d673fb..42ea2ddde 100644 --- a/server/src/internal/analytics/actions/listRawEvents.ts +++ b/server/src/internal/analytics/actions/listRawEvents.ts @@ -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", { diff --git a/server/src/internal/analytics/internalAnalyticsRouter.ts b/server/src/internal/analytics/internalAnalyticsRouter.ts index b89539251..379722bf1 100644 --- a/server/src/internal/analytics/internalAnalyticsRouter.ts +++ b/server/src/internal/analytics/internalAnalyticsRouter.ts @@ -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(); 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); diff --git a/server/src/internal/analytics/internalHandlers/handleGetEventNames.ts b/server/src/internal/analytics/internalHandlers/handleGetEventNames.ts index 9c833b8f8..1ade87615 100644 --- a/server/src/internal/analytics/internalHandlers/handleGetEventNames.ts +++ b/server/src/internal/analytics/internalHandlers/handleGetEventNames.ts @@ -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(); diff --git a/server/src/internal/analytics/internalHandlers/handleQueryEvents.ts b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts similarity index 92% rename from server/src/internal/analytics/internalHandlers/handleQueryEvents.ts rename to server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts index dab9575e2..9824eddd2 100644 --- a/server/src/internal/analytics/internalHandlers/handleQueryEvents.ts +++ b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts @@ -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; diff --git a/server/src/internal/analytics/internalHandlers/handleQueryRawEvents.ts b/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts similarity index 83% rename from server/src/internal/analytics/internalHandlers/handleQueryRawEvents.ts rename to server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts index 11e8d3946..732a173c3 100644 --- a/server/src/internal/analytics/internalHandlers/handleQueryRawEvents.ts +++ b/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts @@ -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; diff --git a/server/src/internal/analytics/internalHandlers/handleListEventNames.ts b/server/src/internal/analytics/internalHandlers/handleListEventNames.ts index c39d1f680..da2de23c6 100644 --- a/server/src/internal/analytics/internalHandlers/handleListEventNames.ts +++ b/server/src/internal/analytics/internalHandlers/handleListEventNames.ts @@ -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(), diff --git a/server/src/internal/analytics/legacyAnalyticsRouter.ts b/server/src/internal/analytics/legacyAnalyticsRouter.ts index 792067656..4566e2893 100644 --- a/server/src/internal/analytics/legacyAnalyticsRouter.ts +++ b/server/src/internal/analytics/legacyAnalyticsRouter.ts @@ -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(); -legacyAnalyticsRouter.post("", ...handleAggregateEvents); +legacyAnalyticsRouter.post("", ...handleExternalAggregateEvents); diff --git a/server/src/internal/balances/track/utils/handleEventIdempotencyKey.ts b/server/src/internal/balances/track/utils/handleEventIdempotencyKey.ts index 2004f1de9..868621e16 100644 --- a/server/src/internal/balances/track/utils/handleEventIdempotencyKey.ts +++ b/server/src/internal/balances/track/utils/handleEventIdempotencyKey.ts @@ -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); diff --git a/server/src/internal/billing/attachPreview/attachParamsToChanges.ts b/server/src/internal/billing/attachPreview/attachParamsToChanges.ts new file mode 100644 index 000000000..c29cc8c3c --- /dev/null +++ b/server/src/internal/billing/attachPreview/attachParamsToChanges.ts @@ -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 { + 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 { + 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 }; +}; diff --git a/server/src/internal/billing/attachPreview/attachParamsToPreview.ts b/server/src/internal/billing/attachPreview/attachParamsToPreview.ts index 86f03a9e1..eeb7216d3 100644 --- a/server/src/internal/billing/attachPreview/attachParamsToPreview.ts +++ b/server/src/internal/billing/attachPreview/attachParamsToPreview.ts @@ -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, }; }; diff --git a/server/src/internal/billing/v2/handlers/handlePreviewAttach.ts b/server/src/internal/billing/v2/handlers/handlePreviewAttach.ts index e13c1f51a..6695202c8 100644 --- a/server/src/internal/billing/v2/handlers/handlePreviewAttach.ts +++ b/server/src/internal/billing/v2/handlers/handlePreviewAttach.ts @@ -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, + ); }, }); diff --git a/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts b/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts index 54280c1a6..802adafe1 100644 --- a/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts +++ b/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts @@ -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 }); }, }); diff --git a/server/src/internal/events/eventsRouter.ts b/server/src/internal/events/eventsRouter.ts index 8776799dd..2c60d964a 100644 --- a/server/src/internal/events/eventsRouter.ts +++ b/server/src/internal/events/eventsRouter.ts @@ -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(); -eventsRouter.post("aggregate", ...handleAggregateEvents); -eventsRouter.post("list", ...handleListEvents); +eventsRouter.post("aggregate", ...handleExternalAggregateEvents); +eventsRouter.post("list", ...handleExternalListEvents); diff --git a/server/src/internal/events/handlers/handleAggregateEvents.ts b/server/src/internal/events/handlers/handleExternalAggregateEvents.ts similarity index 88% rename from server/src/internal/events/handlers/handleAggregateEvents.ts rename to server/src/internal/events/handlers/handleExternalAggregateEvents.ts index c47c50abe..8462c25a3 100644 --- a/server/src/internal/events/handlers/handleAggregateEvents.ts +++ b/server/src/internal/events/handlers/handleExternalAggregateEvents.ts @@ -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", diff --git a/server/src/internal/events/handlers/handleExternalListEvents.ts b/server/src/internal/events/handlers/handleExternalListEvents.ts new file mode 100644 index 000000000..e66ba4724 --- /dev/null +++ b/server/src/internal/events/handlers/handleExternalListEvents.ts @@ -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(result); + }, +}); diff --git a/server/src/internal/events/handlers/handleListEvents.ts b/server/src/internal/events/handlers/handleListEvents.ts deleted file mode 100644 index 3c0d394c9..000000000 --- a/server/src/internal/events/handlers/handleListEvents.ts +++ /dev/null @@ -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(result); - }, -}); diff --git a/server/src/internal/misc/configs/handlers/handleNukeOrganisationConfiguration.ts b/server/src/internal/misc/configs/handlers/handleNukeOrganisationConfiguration.ts index 81c375601..c08e03136 100644 --- a/server/src/internal/misc/configs/handlers/handleNukeOrganisationConfiguration.ts +++ b/server/src/internal/misc/configs/handlers/handleNukeOrganisationConfiguration.ts @@ -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" }); }, }); diff --git a/server/src/internal/misc/configs/handlers/handlePushOrganisationConfiguration.ts b/server/src/internal/misc/configs/handlers/handlePushOrganisationConfiguration.ts index 5ed1dc3d6..9da072ef4 100644 --- a/server/src/internal/misc/configs/handlers/handlePushOrganisationConfiguration.ts +++ b/server/src/internal/misc/configs/handlers/handlePushOrganisationConfiguration.ts @@ -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, diff --git a/server/src/internal/misc/idempotency/checkIdempotencyKey.ts b/server/src/internal/misc/idempotency/checkIdempotencyKey.ts index 7769ea2ee..f1fe3193d 100644 --- a/server/src/internal/misc/idempotency/checkIdempotencyKey.ts +++ b/server/src/internal/misc/idempotency/checkIdempotencyKey.ts @@ -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 => { // 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", diff --git a/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts b/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts index 5b9d55975..98feadbab 100644 --- a/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts +++ b/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts @@ -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>, }; - // 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(); + 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`, ); diff --git a/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts b/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts index 233b6ab19..a66b2ffaf 100644 --- a/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts +++ b/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts @@ -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; - // ============ 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(); 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: { diff --git a/server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts b/server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts new file mode 100644 index 000000000..786429b9b --- /dev/null +++ b/server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts @@ -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(); + + 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; diff --git a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts new file mode 100644 index 000000000..6ebac3907 --- /dev/null +++ b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts @@ -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) => { + 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.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, + }, +}; diff --git a/server/src/internal/misc/rateLimiter/rateLimitFactory.ts b/server/src/internal/misc/rateLimiter/rateLimitFactory.ts new file mode 100644 index 000000000..03f060119 --- /dev/null +++ b/server/src/internal/misc/rateLimiter/rateLimitFactory.ts @@ -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): 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, + evalsha: ( + sha: string, + keys: string[], + args: TArgs, + ): Promise => { + return redis.evalsha( + sha, + keys.length, + ...keys, + ...(args as (string | number | Buffer)[]), + ) as Promise; + }, + 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>; + +export const getLimiterForType = (type: RateLimitType) => limiters[type]; + +export const getRateLimitKey = async ({ + c, + rateLimitType, +}: { + c: Context; + rateLimitType: RateLimitType; +}): Promise => { + 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}`; + } + } +}; diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index 2e1a1f08c..04d896267 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -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 { + // 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 { // 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({ diff --git a/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyEnvironment.ts b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyEnvironment.ts index 9d723323c..49b87bb30 100644 --- a/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyEnvironment.ts +++ b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyEnvironment.ts @@ -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", }); diff --git a/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts b/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts index 4f866ebb0..eaafd5b64 100644 --- a/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts +++ b/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts @@ -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" }); }, }); diff --git a/server/src/internal/products/handlers/handleListPlans.ts b/server/src/internal/products/handlers/handleListPlans.ts index aa3437df0..b0ef6ffbe 100644 --- a/server/src/internal/products/handlers/handleListPlans.ts +++ b/server/src/internal/products/handlers/handleListPlans.ts @@ -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( diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts index 6658f2bad..d8a01922a 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts @@ -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); }, }); diff --git a/server/src/internal/products/internalHandlers/handleGetProducts.ts b/server/src/internal/products/internalHandlers/handleGetProducts.ts index a36500b61..527150f99 100644 --- a/server/src/internal/products/internalHandlers/handleGetProducts.ts +++ b/server/src/internal/products/internalHandlers/handleGetProducts.ts @@ -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) => diff --git a/server/src/internal/products/productCacheUtils.ts b/server/src/internal/products/productCacheUtils.ts new file mode 100644 index 000000000..fcbba7803 --- /dev/null +++ b/server/src/internal/products/productCacheUtils.ts @@ -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 => { + // 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; +}) => { + 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 => { + 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); +}; diff --git a/server/src/internal/products/productUtils/detectProductVariant.ts b/server/src/internal/products/productUtils/detectProductVariant.ts index 51cfaca7f..9e7eef4bc 100644 --- a/server/src/internal/products/productUtils/detectProductVariant.ts +++ b/server/src/internal/products/productUtils/detectProductVariant.ts @@ -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; diff --git a/server/src/queue/initWorkers.ts b/server/src/queue/initWorkers.ts index 513b1915d..12f9d9b59 100644 --- a/server/src/queue/initWorkers.ts +++ b/server/src/queue/initWorkers.ts @@ -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 }); }; diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index 965af507b..7576d57bd 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -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()); diff --git a/server/tests/balances/check/loose/loose-expiry-cross-version.test.ts b/server/tests/balances/check/loose/loose-expiry-cross-version.test.ts new file mode 100644 index 000000000..1508c9062 --- /dev/null +++ b/server/tests/balances/check/loose/loose-expiry-cross-version.test.ts @@ -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); +}); diff --git a/server/tests/integration/balances/track/track-tinybird-migration.test.ts b/server/tests/integration/balances/track/track-tinybird-migration.test.ts index 69f8aa52b..c6907dd04 100644 --- a/server/tests/integration/balances/track/track-tinybird-migration.test.ts +++ b/server/tests/integration/balances/track/track-tinybird-migration.test.ts @@ -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({ diff --git a/server/tests/integration/others/rate-limits/rate-limit-attach.test.ts b/server/tests/integration/others/rate-limits/rate-limit-attach.test.ts new file mode 100644 index 000000000..dfe4d60e9 --- /dev/null +++ b/server/tests/integration/others/rate-limits/rate-limit-attach.test.ts @@ -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(); +}); diff --git a/server/tests/integration/others/rate-limits/rate-limit-events-aggregate.test.ts b/server/tests/integration/others/rate-limits/rate-limit-events-aggregate.test.ts new file mode 100644 index 000000000..3497df119 --- /dev/null +++ b/server/tests/integration/others/rate-limits/rate-limit-events-aggregate.test.ts @@ -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(); +}); diff --git a/server/tests/integration/others/rate-limits/rate-limit-events-list.test.ts b/server/tests/integration/others/rate-limits/rate-limit-events-list.test.ts new file mode 100644 index 000000000..f263696cc --- /dev/null +++ b/server/tests/integration/others/rate-limits/rate-limit-events-list.test.ts @@ -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(); +}); diff --git a/server/tests/integration/others/rate-limits/rate-limit-events-query.test.ts b/server/tests/integration/others/rate-limits/rate-limit-events-query.test.ts new file mode 100644 index 000000000..dd8be8e1c --- /dev/null +++ b/server/tests/integration/others/rate-limits/rate-limit-events-query.test.ts @@ -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(); +}); diff --git a/server/tests/integration/others/rate-limits/rate-limit-list-products.test.ts b/server/tests/integration/others/rate-limits/rate-limit-list-products.test.ts new file mode 100644 index 000000000..a79a7a908 --- /dev/null +++ b/server/tests/integration/others/rate-limits/rate-limit-list-products.test.ts @@ -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(); +}); diff --git a/server/tests/scenarios/attach/attach-paid-default-scenario.test.ts b/server/tests/scenarios/attach/attach-paid-default-scenario.test.ts new file mode 100644 index 000000000..cd1626a81 --- /dev/null +++ b/server/tests/scenarios/attach/attach-paid-default-scenario.test.ts @@ -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: [], + }); +}); diff --git a/server/tests/scenarios/checkout/confirm-paid-no-pm-scenario.test.ts b/server/tests/scenarios/checkout/confirm-paid-no-pm-scenario.test.ts deleted file mode 100644 index acf7b503e..000000000 --- a/server/tests/scenarios/checkout/confirm-paid-no-pm-scenario.test.ts +++ /dev/null @@ -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 }, -); diff --git a/server/tests/scenarios/checkout/downgrade-plan-scenario.test.ts b/server/tests/scenarios/checkout/downgrade-plan-scenario.test.ts index 3fd061575..e7b55c2c6 100644 --- a/server/tests/scenarios/checkout/downgrade-plan-scenario.test.ts +++ b/server/tests/scenarios/checkout/downgrade-plan-scenario.test.ts @@ -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"; diff --git a/server/tinybird/materializations/events_by_timestamp_mv.datasource b/server/tinybird/materializations/events_by_timestamp_mv.datasource index 7c7cb5303..aecf80433 100644 --- a/server/tinybird/materializations/events_by_timestamp_mv.datasource +++ b/server/tinybird/materializations/events_by_timestamp_mv.datasource @@ -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 > diff --git a/server/tinybird/pipes/list_events.pipe b/server/tinybird/pipes/list_events.pipe index dc1903b43..d609f4311 100644 --- a/server/tinybird/pipes/list_events.pipe +++ b/server/tinybird/pipes/list_events.pipe @@ -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. diff --git a/server/tinybird/pipes/list_events_paginated.pipe b/server/tinybird/pipes/list_events_paginated.pipe new file mode 100644 index 000000000..3e807654f --- /dev/null +++ b/server/tinybird/pipes/list_events_paginated.pipe @@ -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) }} diff --git a/shared/api/_openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts b/shared/api/_openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts index 847f54f1c..d56080d8d 100644 --- a/shared/api/_openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts +++ b/shared/api/_openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts @@ -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", diff --git a/shared/api/balances/track/trackLegacyData.ts b/shared/api/balances/track/trackLegacyData.ts index 2444f57a0..df318c40a 100644 --- a/shared/api/balances/track/trackLegacyData.ts +++ b/shared/api/balances/track/trackLegacyData.ts @@ -4,4 +4,4 @@ export const TrackLegacyDataSchema = z.object({ feature_id: z.string(), }); -type TrackLegacyData = z.infer; +export type TrackLegacyData = z.infer; diff --git a/shared/api/billing/common/attachPreviewResponse.ts b/shared/api/billing/common/attachPreviewResponse.ts new file mode 100644 index 000000000..3f5bae7fa --- /dev/null +++ b/shared/api/billing/common/attachPreviewResponse.ts @@ -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; diff --git a/shared/api/common/epochUtils.ts b/shared/api/common/epochUtils.ts new file mode 100644 index 000000000..7b5aa0d92 --- /dev/null +++ b/shared/api/common/epochUtils.ts @@ -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}`; +}; diff --git a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts index b01e2175c..a09b3fd57 100644 --- a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts +++ b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts @@ -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; }); } diff --git a/shared/api/customers/cusFeatures/previousVersions/apiCusFeatureV3.ts b/shared/api/customers/cusFeatures/previousVersions/apiCusFeatureV3.ts index e64766c3e..95a6aa9ed 100644 --- a/shared/api/customers/cusFeatures/previousVersions/apiCusFeatureV3.ts +++ b/shared/api/customers/cusFeatures/previousVersions/apiCusFeatureV3.ts @@ -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, }), diff --git a/shared/index.ts b/shared/index.ts index e9d253912..a7bf58386 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -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"; diff --git a/shared/models/billingModels/cusProductActions.ts b/shared/models/billingModels/cusProductActions.ts new file mode 100644 index 000000000..756d510d2 --- /dev/null +++ b/shared/models/billingModels/cusProductActions.ts @@ -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), + }); diff --git a/shared/models/eventModels/eventTypes.ts b/shared/models/eventModels/eventTypes.ts index 7a5e62b9b..cb782ccd9 100644 --- a/shared/models/eventModels/eventTypes.ts +++ b/shared/models/eventModels/eventTypes.ts @@ -29,6 +29,7 @@ export type TimeseriesEventsParams = TotalEventsParams & { group_by?: string; no_count?: boolean; timezone?: string; + enforceGroupLimit?: boolean; }; export type CalculateDateRangeParams = Omit< diff --git a/shared/utils/agentTypes.ts b/shared/utils/agentTypes.ts new file mode 100644 index 000000000..b2d0abfdb --- /dev/null +++ b/shared/utils/agentTypes.ts @@ -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 = {}; + 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), + }; +} diff --git a/shared/utils/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 637bb8b59..94768e850 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -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; diff --git a/vite/src/app/layout.tsx b/vite/src/app/layout.tsx index 5b71290aa..95daf077e 100644 --- a/vite/src/app/layout.tsx +++ b/vite/src/app/layout.tsx @@ -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 (
- {env === AppEnv.Sandbox && ( -
-

- You're in sandbox -

-
- )} + {env === AppEnv.Sandbox && }
@@ -143,8 +138,7 @@ const MainContent = ({ className="w-full h-full flex flex-col overflow-hidden rounded-xl border relative" > {env === AppEnv.Sandbox && ( -
-

You're in sandbox

+ {!org?.deployed && ( } 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 )} -
+
)} void; + onSubmit: () => void; + placeholder?: string; + isLoading?: boolean; + className?: string; +}) { + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (value.trim() && !isLoading) onSubmit(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && value.trim() && !isLoading) { + e.preventDefault(); + onSubmit(); + } + }; + + return ( +
+
+ onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={isLoading} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-t4" + /> + + {isLoading ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/vite/src/components/ai-elements/message.tsx b/vite/src/components/ai-elements/message.tsx index 657b2dddb..7166c8a84 100644 --- a/vite/src/components/ai-elements/message.tsx +++ b/vite/src/components/ai-elements/message.tsx @@ -45,7 +45,7 @@ export const MessageContent = ({
().nullable(), version: z.number().positive().optional(), + trialLength: z.number().positive().nullable(), + trialDuration: z.enum(FreeTrialDuration), + trialEnabled: z.boolean(), + planSchedule: z.custom().nullable(), }); export type AttachForm = z.infer; diff --git a/vite/src/components/forms/attach-v2/components/AttachFooter.tsx b/vite/src/components/forms/attach-v2/components/AttachFooter.tsx index 985003c31..eb6e2ad77 100644 --- a/vite/src/components/forms/attach-v2/components/AttachFooter.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachFooter.tsx @@ -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() { > - diff --git a/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx b/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx index a421048f3..4c69b5db2 100644 --- a/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx @@ -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 ( -
- Plan Configuration - {hasCustomizations && Custom} -
- ); -} +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 ; + } 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 ( - } - withSeparator - > - {(product?.items?.length ?? 0) > 0 || deletedItems.length > 0 ? ( - <> -
- -
- -
- {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 ( - - - - ); - })} - {deletedItems.map((item: ProductItem, index: number) => ( - - - - ))} - - - -
-
- - ) : ( - - )} + + + +

+ +

+
+ {hasItems ? ( + form.setFieldValue("trialEnabled", false), + }} + useStaggerAnimation + /> + ) : ( + + + + )} +
); } diff --git a/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx b/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx new file mode 100644 index 000000000..a41ce415b --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx @@ -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 ( + + + {/* Section title - static content with disabled buttons */} + +

+ + + Plan Configuration + + + } + variant="secondary" + className="h-7 whitespace-nowrap" + disabled + > + Settings + + } + variant="secondary" + className="h-7 whitespace-nowrap" + disabled + > + Free Trial + + + +

+
+ + {/* Price display skeleton */} + + + + + + + + {/* Item rows skeleton */} + {[0, 1].map((i) => ( + +
+
+
+ + + +
+ +
+
+
+ ))} + + {/* Edit button skeleton */} + + + +
+
+ ); +} diff --git a/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx b/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx index 282c137be..a1bd3aabe 100644 --- a/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx @@ -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 ( - - - + + + + + ); } return ( - + + + ); } diff --git a/vite/src/components/forms/attach-v2/components/AttachProductSelection.tsx b/vite/src/components/forms/attach-v2/components/AttachProductSelection.tsx index 5e83c69d0..337eea2ea 100644 --- a/vite/src/components/forms/attach-v2/components/AttachProductSelection.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachProductSelection.tsx @@ -20,6 +20,7 @@ export function AttachProductSelection() { {(field) => ( ({ 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 ? ( diff --git a/vite/src/components/forms/attach-v2/components/AttachSectionTitle.tsx b/vite/src/components/forms/attach-v2/components/AttachSectionTitle.tsx new file mode 100644 index 000000000..a32f786f6 --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachSectionTitle.tsx @@ -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 ( + + + Plan Configuration + {hasCustomizations && ( + + + + + + This plan's configuration has been customized. See changes below. + + + )} + + + + + + + } + 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 + + + + {trialIsActive + ? "Trial configured - click to edit" + : "Add a free trial"} + + + + + ); +} diff --git a/vite/src/components/forms/attach-v2/components/AttachSettingsPopover.tsx b/vite/src/components/forms/attach-v2/components/AttachSettingsPopover.tsx new file mode 100644 index 000000000..cbc4f0ce5 --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachSettingsPopover.tsx @@ -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 ( + + + + } + variant="secondary" + className={cn( + "h-7 whitespace-nowrap", + hasCustomSchedule && + "text-blue-400! border-blue-500/50 bg-blue-500/10", + )} + > + Settings + + + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > +
+
+

+ Advanced Configuration +

+

Override default billing behavior

+
+ +
+ Plan Schedule +
+ } + iconOrientation="left" + variant="secondary" + size="sm" + checked={isImmediateSelected} + onCheckedChange={() => handleScheduleChange("immediate")} + className={cn( + "rounded-r-none", + !isImmediateSelected && "border-r-0", + )} + > + Immediately + + } + 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 + +
+
+
+
+
+ ); +} diff --git a/vite/src/components/forms/attach-v2/components/AttachUpdatesSection.tsx b/vite/src/components/forms/attach-v2/components/AttachUpdatesSection.tsx new file mode 100644 index 000000000..5bd7b12b0 --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachUpdatesSection.tsx @@ -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 ( + + + +
+ + +
+
+
+
+ ); +} + +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 ; + } + + 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 ( + + {needsComma && ", "} + {needsAnd && " and "} + + + {change.plan.name} + + + ); + }); + }; + + return ( + + + + + Attaching{" "} + + {product.name} + {outgoing.length > 0 && <> and removing {renderOutgoingPlans()}} + + + + + ); +} diff --git a/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx b/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx index 238728629..b58a03eab 100644 --- a/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx +++ b/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx @@ -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( () => ({ - formContext, form, formValues, features, @@ -250,7 +241,6 @@ export function AttachFormProvider({ handleInvoiceAttach, }), [ - formContext, form, formValues, features, diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachForm.ts b/vite/src/components/forms/attach-v2/hooks/useAttachForm.ts index 5f10cbab5..c4dae9a9d 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachForm.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachForm.ts @@ -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, diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts b/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts index ffcf3b56b..68ba9d0cd 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts @@ -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( + const response = await axiosInstance.post( "/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"); } diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachPreview.ts b/vite/src/components/forms/attach-v2/hooks/useAttachPreview.ts index ab51d35f0..7b58afd3d 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachPreview.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachPreview.ts @@ -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; - 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( + const response = await axiosInstance.post( "/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; diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts index 525bcfddf..84c5c69f7 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts @@ -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; 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( () => diff --git a/vite/src/components/forms/attach-v2/index.ts b/vite/src/components/forms/attach-v2/index.ts index 12ec09ab6..8283959ad 100644 --- a/vite/src/components/forms/attach-v2/index.ts +++ b/vite/src/components/forms/attach-v2/index.ts @@ -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"; diff --git a/vite/src/components/forms/attach-v2/utils/attachDiffUtils.ts b/vite/src/components/forms/attach-v2/utils/attachDiffUtils.ts new file mode 100644 index 000000000..feb61eca9 --- /dev/null +++ b/vite/src/components/forms/attach-v2/utils/attachDiffUtils.ts @@ -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, + }), + ); +} diff --git a/vite/src/components/forms/cancel-subscription/components/CancelFooter.tsx b/vite/src/components/forms/cancel-subscription/components/CancelFooter.tsx index e36ba24f2..a46cfc8da 100644 --- a/vite/src/components/forms/cancel-subscription/components/CancelFooter.tsx +++ b/vite/src/components/forms/cancel-subscription/components/CancelFooter.tsx @@ -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"; diff --git a/vite/src/components/forms/cancel-subscription/components/CancelModeSection.tsx b/vite/src/components/forms/cancel-subscription/components/CancelModeSection.tsx index caa22dbd2..08b22bd4d 100644 --- a/vite/src/components/forms/cancel-subscription/components/CancelModeSection.tsx +++ b/vite/src/components/forms/cancel-subscription/components/CancelModeSection.tsx @@ -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; diff --git a/vite/src/components/forms/shared/PlanItemsSection.tsx b/vite/src/components/forms/shared/PlanItemsSection.tsx new file mode 100644 index 000000000..3627c7d58 --- /dev/null +++ b/vite/src/components/forms/shared/PlanItemsSection.tsx @@ -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; + initialPrepaidOptions: Record; + 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 ( + + ); + } + + const renderPriceDisplay = () => { + if (priceChange) { + return ( + + + {priceChange.oldPrice} + {priceChange.oldIntervalText && ` ${priceChange.oldIntervalText}`} + + -> + {priceChange.newPrice} + {priceChange.newIntervalText} + + ); + } + return ; + }; + + 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 ( + + + + ); + }; + + const renderDeletedItemRow = (item: ProductItem, index: number) => ( + + + + ); + + const renderVersionChangeRow = () => { + if (!showVersionChange || !versionChange) return null; + return ( + + + + ); + }; + + const renderTrialEditor = () => { + if (!trialConfig || !showTrialEditor) return null; + + if (isComplexTrialConfig(trialConfig)) { + const { trialState } = trialConfig; + return ( + + trialState.setIsTrialExpanded(false)} + onRevert={trialState.handleRevertTrial} + /> + + ); + } + + return ( + + {trialConfig.trialEnabled && ( + + + + )} + + ); + }; + + const renderEditButton = () => ( + + + + ); + + if (useStaggerAnimation) { + return ( + + + + {renderPriceDisplay()} + + {product?.items?.map(renderItemRow)} + {deletedItems.map(renderDeletedItemRow)} + {renderTrialEditor()} + {renderEditButton()} + + + ); + } + + return ( + <> +
+ {renderPriceDisplay()} +
+ +
+ {product?.items?.map(renderItemRow)} + {deletedItems.map(renderDeletedItemRow)} + {renderVersionChangeRow()} + {renderTrialEditor()} + {renderEditButton()} +
+
+ + ); +} diff --git a/vite/src/components/forms/shared/index.ts b/vite/src/components/forms/shared/index.ts new file mode 100644 index 000000000..21baf14c9 --- /dev/null +++ b/vite/src/components/forms/shared/index.ts @@ -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"; diff --git a/vite/src/components/forms/uncancel-subscription/components/UncancelFooter.tsx b/vite/src/components/forms/uncancel-subscription/components/UncancelFooter.tsx index 31da364bc..4aa8b8014 100644 --- a/vite/src/components/forms/uncancel-subscription/components/UncancelFooter.tsx +++ b/vite/src/components/forms/uncancel-subscription/components/UncancelFooter.tsx @@ -1,15 +1,20 @@ -import { motion } from "motion/react"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { useUpdateSubscriptionFormContext } from "@/components/forms/update-subscription-v2"; import { Button } from "@/components/v2/buttons/Button"; import { SheetFooter } from "@/components/v2/sheets/SharedSheetComponents"; const FOOTER_DELAY_MS = 350; +function FooterButton({ children }: { children: ReactNode }) { + return
{children}
; +} + export function UncancelFooter() { - const { isPending, previewQuery, handleConfirm } = + const { isPending, previewQuery, handleConfirm, form, formValues } = useUpdateSubscriptionFormContext(); + const isCancelMode = formValues.cancelAction === "cancel_immediately"; + const isLoading = previewQuery.isLoading; const hasError = !!previewQuery.error; const isReady = !isLoading && !hasError; @@ -24,15 +29,57 @@ export function UncancelFooter() { setShowFooter(false); }, [isReady]); + const handleCancelImmediatelyClick = () => { + form.setFieldValue("cancelAction", "cancel_immediately"); + form.setFieldValue("billingBehavior", "prorate_immediately"); + }; + + const handleGoBack = () => { + form.setFieldValue("cancelAction", "uncancel"); + }; + if (!showFooter) return null; + if (isCancelMode) { + return ( + + + + + + + + + ); + } + return ( - - + + + + + - + ); } diff --git a/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx b/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx index aec3f42d3..04573c9de 100644 --- a/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx @@ -1,25 +1,10 @@ -import type { ProductItem } from "@autumn/shared"; -import { - buildEditsForItem, - featureToOptions, - formatAmount, - formatInterval, - isPriceItem, - UsageModel, -} from "@autumn/shared"; -import { PencilSimpleIcon } from "@phosphor-icons/react"; -import { LayoutGroup, motion } from "motion/react"; +import { formatAmount, formatInterval, isPriceItem } from "@autumn/shared"; import { useMemo } from "react"; -import { Button } from "@/components/v2/buttons/Button"; +import { PlanItemsSection } from "@/components/forms/shared"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; import { useOrg } from "@/hooks/common/useOrg"; -import { LAYOUT_TRANSITION } from "../constants/animationConstants"; import { useUpdateSubscriptionFormContext } from "../context/UpdateSubscriptionFormProvider"; -import { PriceDisplay } from "./PriceDisplay"; import { SectionTitle } from "./SectionTitle"; -import { SubscriptionItemRow } from "./SubscriptionItemRow"; -import { TrialEditorRow } from "./TrialEditorRow"; -import { VersionChangeRow } from "./VersionChangeRow"; export function EditPlanSection() { const { @@ -41,19 +26,6 @@ export function EditPlanSection() { 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]) ?? - [], - ); - - const currentFeatureIds = new Set( - product?.items?.map((i) => i.feature_id).filter(Boolean) ?? [], - ); - const deletedItems = - originalItems?.filter( - (i) => i.feature_id && !currentFeatureIds.has(i.feature_id), - ) ?? []; - const priceChange = useMemo(() => { const originalPriceItem = originalItems?.find((i) => isPriceItem(i)); const currentPriceItem = product?.items?.find((i) => isPriceItem(i)); @@ -91,8 +63,6 @@ export function EditPlanSection() { if (interval) { return formatInterval({ interval, intervalCount }); } - // Only show "one-time" if there's a price item with no interval - // Otherwise it's variable/usage-based return hasPriceItem ? "one-time" : null; }; @@ -118,10 +88,10 @@ export function EditPlanSection() { }, [originalItems, product?.items, currency]); const selectedVersion = form.getFieldValue("version"); - const showVersionChange = - currentVersion !== undefined && - selectedVersion !== undefined && - selectedVersion !== currentVersion; + const versionChange = + currentVersion !== undefined && selectedVersion !== undefined + ? { currentVersion, selectedVersion } + : null; return ( - {(product?.items?.length ?? 0) > 0 || deletedItems.length > 0 ? ( - <> -
- {priceChange ? ( - - - {priceChange.oldPrice} - {priceChange.oldIntervalText && - ` ${priceChange.oldIntervalText}`} - - - - {priceChange.newPrice} - - {priceChange.newIntervalText} - - ) : ( - - )} -
- -
- {product?.items?.map((item: ProductItem, index: number) => { - if (!item.feature_id) return null; - - const featureId = item.feature_id; - const featureForOptions = features?.find( - (f) => f.id === featureId, - ); - const prepaidOption = featureToOptions({ - feature: featureForOptions, - options: customerProduct?.options, - }); - - const isPrepaid = item.usage_model === UsageModel.Prepaid; - const currentPrepaidQuantity = isPrepaid - ? prepaidOptions[featureId] - : prepaidOption?.quantity; - const initialPrepaidQuantity = isPrepaid - ? initialPrepaidOptions[featureId] - : undefined; - - const originalItem = originalItemsMap.get(featureId); - const isCreated = - !originalItem && originalItems && originalItems.length > 0; - const edits = buildEditsForItem({ - updatedItem: item, - originalItem, - updatedPrepaidQuantity: currentPrepaidQuantity, - originalPrepaidQuantity: initialPrepaidQuantity, - }); - - return ( - - - - ); - })} - {deletedItems.map((item: ProductItem, index: number) => ( - - - - ))} - {showVersionChange && ( - - - - )} - {(trialState.isTrialExpanded || trialState.removeTrial) && ( - - trialState.setIsTrialExpanded(false)} - onRevert={trialState.handleRevertTrial} - /> - - )} - - - -
-
- - ) : ( - - )} +
); } diff --git a/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx b/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx index 86f65f366..c8c03ab8f 100644 --- a/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx @@ -11,6 +11,7 @@ import { } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "motion/react"; import { useState } from "react"; +import type { UseAttachForm } from "@/components/forms/attach-v2/hooks/useAttachForm"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { Tooltip, @@ -32,7 +33,7 @@ import { StatusBadge } from "./StatusBadge"; interface SubscriptionItemRowProps { item: ProductItem; edits?: ItemEdit[]; - form?: UseUpdateSubscriptionForm; + form?: UseUpdateSubscriptionForm | UseAttachForm; featureId?: string; prepaidQuantity?: number | null; isDeleted?: boolean; diff --git a/vite/src/components/forms/update-subscription-v2/components/TrialEditorRow.tsx b/vite/src/components/forms/update-subscription-v2/components/TrialEditorRow.tsx index 72e85a21f..4a2e40aaf 100644 --- a/vite/src/components/forms/update-subscription-v2/components/TrialEditorRow.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/TrialEditorRow.tsx @@ -9,6 +9,7 @@ import { import { useStore } from "@tanstack/react-form"; import { AnimatePresence, motion } from "motion/react"; import { useRef, useState } from "react"; +import type { UseAttachForm } from "@/components/forms/attach-v2/hooks/useAttachForm"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { Tooltip, @@ -26,22 +27,22 @@ import { getTrialRingClass } from "../utils/ringClassUtils"; import { StatusBadge } from "./StatusBadge"; interface TrialEditorRowProps { - form: UseUpdateSubscriptionForm; - isCurrentlyTrialing: boolean; - initialTrialLength: number | null; - initialTrialFormatted: string | null; - removeTrial: boolean; - onEndTrial: () => void; + form: UseUpdateSubscriptionForm | UseAttachForm; + isCurrentlyTrialing?: boolean; + initialTrialLength?: number | null; + initialTrialFormatted?: string | null; + removeTrial?: boolean; + onEndTrial?: () => void; onCollapse: () => void; - onRevert: () => void; + onRevert?: () => void; } export function TrialEditorRow({ form, - isCurrentlyTrialing, - initialTrialLength, - initialTrialFormatted, - removeTrial, + isCurrentlyTrialing = false, + initialTrialLength = null, + initialTrialFormatted = null, + removeTrial = false, onEndTrial, onCollapse, onRevert, @@ -105,7 +106,7 @@ export function TrialEditorRow({ }, 0); }; - if (removeTrial) { + if (removeTrial && onRevert) { return (
{ + if (queryKeyDeps === debouncedQueryKey) { + setIsDebouncing(false); + return; + } + + setIsDebouncing(true); const timer = setTimeout(() => { setDebouncedQueryKey(queryKeyDeps); + setIsDebouncing(false); }, 300); return () => clearTimeout(timer); - }, [queryKeyDeps]); - - const isDebouncing = queryKeyDeps !== debouncedQueryKey; + }, [queryKeyDeps, debouncedQueryKey]); const query = useQuery({ queryKey: ["update-subscription-preview", debouncedQueryKey], diff --git a/vite/src/components/general/SandboxBanner.tsx b/vite/src/components/general/SandboxBanner.tsx new file mode 100644 index 000000000..0df0bb67c --- /dev/null +++ b/vite/src/components/general/SandboxBanner.tsx @@ -0,0 +1,18 @@ +import { FlaskIcon } from "@phosphor-icons/react"; + +export function SandboxBanner({ children }: { children?: React.ReactNode }) { + return ( +
+ {/* Content container - matches page content alignment */} +
+ {/* Left content */} +
+ +

Sandbox

+
+ {/* Right content (optional children) */} + {children} +
+
+ ); +} diff --git a/vite/src/components/general/form/fields/select-field.tsx b/vite/src/components/general/form/fields/select-field.tsx index 5e402bf59..f843a8fa4 100644 --- a/vite/src/components/general/form/fields/select-field.tsx +++ b/vite/src/components/general/form/fields/select-field.tsx @@ -1,12 +1,7 @@ +import { CheckIcon } from "lucide-react"; import { FieldInfo } from "@/components/general/form/field-info"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/v2/selects/Select"; +import { SearchableSelect } from "@/components/v2/selects/SearchableSelect"; import { useFieldContext } from "@/hooks/form/form-context"; export type SelectFieldOption = { @@ -24,6 +19,9 @@ export function SelectField({ hideFieldInfo, selectValueAfter, disabled, + searchable = false, + searchPlaceholder = "Search...", + emptyText = "No results found", }: { label: string; options: SelectFieldOption[]; @@ -33,13 +31,14 @@ export function SelectField({ hideFieldInfo?: boolean; selectValueAfter?: React.ReactNode; disabled?: boolean; + searchable?: boolean; + searchPlaceholder?: string; + emptyText?: string; }) { const field = useFieldContext(); - - // Convert value to string for the Select component (which only accepts strings) const stringValue = String(field.state.value); + const handleChange = (value: string) => { - // Convert back to the original type const typedValue = ( typeof field.state.value === "number" ? Number(value) : value ) as T; @@ -48,37 +47,43 @@ export function SelectField({ return (
- - + renderValue={(opt) => ( + <> + + {opt?.label || placeholder} + + {selectValueAfter && opt && ( + {selectValueAfter} + )} + + )} + renderOption={(opt, isSelected) => ( + <> + {opt.label} + {opt.disabledValue && ( + + {opt.disabledValue} + + )} + {isSelected && !opt.disabledValue && ( + + )} + + )} + /> {textAfter && (
{ const isLast = index === arr.length - 1; - const headerStyle = flexibleTableColumns - ? { - width: `${header.getSize()}px`, - maxWidth: `${header.getSize()}px`, - minWidth: `${header.getSize()}px`, - } - : { width: `${header.getSize()}px` }; + const headerStyle = flexibleTableColumns + ? { + width: `${header.getSize()}px`, + maxWidth: `${header.getSize()}px`, + } + : { width: `${header.getSize()}px` }; return ( ({ cell.column.columnDef.cell, cell.getContext(), ); - const cellStyle = flexibleTableColumns - ? { - width: `${cell.column.getSize()}px`, - maxWidth: `${cell.column.getSize()}px`, - minWidth: `${cell.column.getSize()}px`, - } - : { width: `${cell.column.getSize()}px` }; + const cellStyle = flexibleTableColumns + ? { + width: `${cell.column.getSize()}px`, + maxWidth: `${cell.column.getSize()}px`, + } + : { width: `${cell.column.getSize()}px` }; return (
) : ( -
+
{emptyStateChildren || emptyStateText}
)} diff --git a/vite/src/components/ui/command.tsx b/vite/src/components/ui/command.tsx index e4409ac16..cf64bb840 100644 --- a/vite/src/components/ui/command.tsx +++ b/vite/src/components/ui/command.tsx @@ -98,12 +98,13 @@ function CommandList({ } function CommandEmpty({ + className, ...props }: React.ComponentProps) { return ( ); diff --git a/vite/src/components/v2/InfoRow.tsx b/vite/src/components/v2/InfoRow.tsx index d412cf339..186584ac6 100644 --- a/vite/src/components/v2/InfoRow.tsx +++ b/vite/src/components/v2/InfoRow.tsx @@ -13,9 +13,9 @@ export function InfoRow({ icon, label, value, className, mono }: InfoRowProps) { typeof value !== "string" && typeof value !== "number" && value !== null; return ( -
- {icon &&
{icon}
} -
+
+ {icon &&
{icon}
} +
{label}
diff --git a/vite/src/components/v2/buttons/Button.tsx b/vite/src/components/v2/buttons/Button.tsx index f2ef918da..a64993a83 100644 --- a/vite/src/components/v2/buttons/Button.tsx +++ b/vite/src/components/v2/buttons/Button.tsx @@ -13,17 +13,24 @@ import { cn } from "@/lib/utils"; // focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive const buttonVariants = cva( `inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none - rounded-lg group/btn transition-none w-fit + rounded-lg group/btn transition-none w-fit transition-all duration-100 `, { variants: { variant: { // Custom - primary: `btn-primary-shadow !text-primary-foreground bg-primary border border-transparent hover:bg-primary-btn-hover - active:bg-primary-btn-active active:border-primary-btn-border - focus-visible:bg-primary-btn-active focus-visible:border-primary-btn-border - - btn-primary-dark + primary: ` + !text-primary-foreground + bg-primary + hover:bg-primary/90 + relative overflow-hidden + border + border-primary + before:content-[''] before:absolute before:inset-0 before:z-[1] before:pointer-events-none + dark:hover:before:bg-background/25 dark:before:bg-background/20 dark:hover:before:bg-background/25 + after:content-[''] after:absolute after:inset-0 after:z-[1] after:pointer-events-none + after:bg-[linear-gradient(135deg,color-mix(in_oklch,var(--background)_10%,transparent)_10%,transparent_65%,color-mix(in_oklch,var(--background)_10%,transparent)_100%)] + shadow-sm `, secondary: `bg-interactive-secondary border border-[var(--color-input)] hover:bg-interactive-secondary-hover active:bg-interactive-secondary-hover btn-secondary-shadow @@ -129,7 +136,7 @@ const Button = React.forwardRef( switch (variant) { case "primary": - return "active:!bg-primary active:!border-transparent focus-visible:!bg-primary focus-visible:!border-transparent"; + return ""; case "secondary": return "active:!bg-interactive-secondary-hover active:!border-[var(--color-input)] focus-visible:!bg-interactive-secondary-hover focus-visible:!border-[var(--color-input)]"; @@ -171,7 +178,18 @@ const Button = React.forwardRef( disabled={isLoading || props.disabled} {...props} > - {isLoading ? : children} + {isLoading ? ( + + ) : variant === "primary" ? ( + + {children} + + ) : ( + children + )} ); }, diff --git a/vite/src/components/v2/selects/SearchableSelect.tsx b/vite/src/components/v2/selects/SearchableSelect.tsx new file mode 100644 index 000000000..435059ffa --- /dev/null +++ b/vite/src/components/v2/selects/SearchableSelect.tsx @@ -0,0 +1,167 @@ +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +export type SearchableSelectProps = { + value: string | null; + onValueChange: (value: string) => void; + options: T[]; + getOptionValue: (option: T) => string; + getOptionLabel: (option: T) => string; + getOptionDisabled?: (option: T) => boolean; + renderOption?: (option: T, isSelected: boolean) => ReactNode; + renderValue?: (option: T | undefined) => ReactNode; + placeholder?: string; + searchable?: boolean; + searchPlaceholder?: string; + emptyText?: string; + disabled?: boolean; + triggerClassName?: string; + contentClassName?: string; +}; + +export function SearchableSelect({ + value, + onValueChange, + options, + getOptionValue, + getOptionLabel, + getOptionDisabled, + renderOption, + renderValue, + placeholder = "Select...", + searchable = false, + searchPlaceholder = "Search...", + emptyText = "No results found", + disabled = false, + triggerClassName, + contentClassName, +}: SearchableSelectProps) { + const [open, setOpen] = useState(false); + + const selectedOption = options.find((opt) => getOptionValue(opt) === value); + + const handleSelect = (option: T) => { + if (getOptionDisabled?.(option)) return; + onValueChange(getOptionValue(option)); + setOpen(false); + }; + + const defaultRenderValue = (option: T | undefined) => { + if (!option) return {placeholder}; + return {getOptionLabel(option)}; + }; + + const defaultRenderOption = (option: T, isSelected: boolean) => { + const isDisabled = getOptionDisabled?.(option) ?? false; + return ( + <> + + {getOptionLabel(option)} + + {isSelected && !isDisabled && } + + ); + }; + + return ( + + + + + + { + const option = options.find( + (opt) => getOptionValue(opt) === optionValue, + ); + if (!option) return 0; + const searchLower = search.toLowerCase(); + const labelMatch = getOptionLabel(option) + .toLowerCase() + .includes(searchLower); + const valueMatch = optionValue + .toLowerCase() + .includes(searchLower); + return labelMatch || valueMatch ? 1 : 0; + } + : undefined + } + > + {searchable && } + + {emptyText} + + {options.map((option) => { + const optionValue = getOptionValue(option); + const isSelected = optionValue === value; + const isDisabled = getOptionDisabled?.(option) ?? false; + + return ( + handleSelect(option)} + disabled={isDisabled} + className={cn( + "min-w-0", + isDisabled && "text-t4 pointer-events-none opacity-50", + )} + > + {renderOption + ? renderOption(option, isSelected) + : defaultRenderOption(option, isSelected)} + + ); + })} + + + + + + ); +} diff --git a/vite/src/components/v2/sheets/SharedSheetComponents.tsx b/vite/src/components/v2/sheets/SharedSheetComponents.tsx index 783c6f53d..818140f68 100644 --- a/vite/src/components/v2/sheets/SharedSheetComponents.tsx +++ b/vite/src/components/v2/sheets/SharedSheetComponents.tsx @@ -1,6 +1,7 @@ import { CaretRightIcon } from "@phosphor-icons/react"; import { motion } from "motion/react"; import { useId } from "react"; +import { LAYOUT_TRANSITION as ANIM_LAYOUT_TRANSITION } from "@/components/forms/update-subscription-v2/constants/animationConstants"; import { Separator } from "@/components/v2/separator"; import { type SheetType, useSheetStore } from "@/hooks/stores/useSheetStore"; import { cn } from "@/lib/utils"; @@ -121,9 +122,9 @@ export function SheetSection({ {children}
{withSeparator && ( -
+ -
+ )} ); diff --git a/vite/src/index.css b/vite/src/index.css index 67b3be380..e3a937ad1 100644 --- a/vite/src/index.css +++ b/vite/src/index.css @@ -62,6 +62,7 @@ html { --t6: #aaa; --t7: #666666; --t8: #0f9bff; + --sandbox: #0f9bff; --t9: #121212; --t10: #d1d1d1; --t11: #e3e3e3; @@ -166,6 +167,7 @@ html { --t1: #ddd; --t2: #ccc; --t3: #999; + --sandbox: #0f9bff; --card: #121212; @@ -216,6 +218,7 @@ html { --color-t6: var(--t6); --color-t7: var(--t7); --color-t8: var(--t8); + --color-sandbox: var(--sandbox); --color-t9: var(--t9); --color-t10: var(--t10); --color-t11: var(--t11); diff --git a/vite/src/utils/formatUtils/formatDateUtils.ts b/vite/src/utils/formatUtils/formatDateUtils.ts index e4e5f8389..8b7fd7cb9 100644 --- a/vite/src/utils/formatUtils/formatDateUtils.ts +++ b/vite/src/utils/formatUtils/formatDateUtils.ts @@ -25,10 +25,13 @@ export const formatUnixToDate = ( export const formatUnixToDateTime = ( unix: number | null | undefined, - options?: { ampm?: boolean; case?: TimeCase }, + options?: { ampm?: boolean; case?: TimeCase; withYear?: boolean }, ) => { if (!unix) return { date: "", time: "" }; - const date = format(new Date(unix), "d MMM"); + const date = format( + new Date(unix), + options?.withYear ? "d MMM yyyy" : "d MMM", + ); const pattern = options?.ampm ? "HH:mm a" : "HH:mm"; let time = format(new Date(unix), pattern); diff --git a/vite/src/views/command-bar/CommandBar.tsx b/vite/src/views/command-bar/CommandBar.tsx index e2a3fcdf7..029df079e 100644 --- a/vite/src/views/command-bar/CommandBar.tsx +++ b/vite/src/views/command-bar/CommandBar.tsx @@ -5,7 +5,7 @@ import { FingerprintIcon, GearIcon, } from "@phosphor-icons/react"; -import { useQuery } from "@tanstack/react-query"; +import { useQueries, useQuery } from "@tanstack/react-query"; import { AppEnv } from "autumn-js"; import { CircleUserRoundIcon, @@ -40,7 +40,7 @@ import { CommandRow } from "@/views/command-bar/command-row"; import { calculateRelevanceScore } from "@/views/command-bar/commandUtils"; import { useCommandBarHotkeys } from "@/views/command-bar/useCommandBarHotkeys"; import { handleSwitchOrg } from "@/views/main-sidebar/components/OrgDropdown"; -import { handleEnvChange } from "@/views/main-sidebar/EnvDropdown"; +import { useEnvChange } from "@/views/main-sidebar/EnvDropdown"; type Customer = z.infer; @@ -76,6 +76,7 @@ const CommandBar = () => { const navigate = useNavigate(); const env = useEnv(); + const handleEnvChange = useEnvChange(); const { data: orgs, isPending: isLoadingOrgs } = useListOrganizations(); const axiosInstance = useAxiosInstance(); const { isAdmin } = useAdmin(); @@ -166,45 +167,46 @@ const CommandBar = () => { enabled: open && debouncedSearch.length > 0 && currentPage === "main", }); - // Search users for impersonation - const { data: searchedUsersData, isLoading: searchUsersLoading } = useQuery<{ - rows: User[]; - }>({ - queryKey: ["command-palette-users-search", debouncedSearch], - queryFn: async () => { - const params = new URLSearchParams(); - if (debouncedSearch) params.append("search", debouncedSearch); - const { data } = await axiosInstance.get( - `/admin/users?${params.toString()}`, - ); - return data; - }, - enabled: - open && - debouncedSearch.length > 0 && - currentPage === "impersonate" && - isAdmin, + // Search orgs and users for impersonation (concurrent requests) + const impersonateEnabled = + open && + debouncedSearch.length > 0 && + currentPage === "impersonate" && + isAdmin; + + const [orgsQuery, usersQuery] = useQueries({ + queries: [ + { + queryKey: ["command-palette-orgs-search", debouncedSearch], + queryFn: async () => { + const params = new URLSearchParams(); + if (debouncedSearch) params.append("search", debouncedSearch); + const { data } = await axiosInstance.get<{ rows: Org[] }>( + `/admin/orgs?${params.toString()}`, + ); + return data; + }, + enabled: impersonateEnabled, + }, + { + queryKey: ["command-palette-users-search", debouncedSearch], + queryFn: async () => { + const params = new URLSearchParams(); + if (debouncedSearch) params.append("search", debouncedSearch); + const { data } = await axiosInstance.get<{ rows: User[] }>( + `/admin/users?${params.toString()}`, + ); + return data; + }, + enabled: impersonateEnabled, + }, + ], }); - // Search orgs for impersonation - const { data: searchedOrgsData, isLoading: searchOrgsLoading } = useQuery<{ - rows: Org[]; - }>({ - queryKey: ["command-palette-orgs-search", debouncedSearch], - queryFn: async () => { - const params = new URLSearchParams(); - if (debouncedSearch) params.append("search", debouncedSearch); - const { data } = await axiosInstance.get( - `/admin/orgs?${params.toString()}`, - ); - return data; - }, - enabled: - open && - debouncedSearch.length > 0 && - currentPage === "impersonate" && - isAdmin, - }); + const searchedOrgsData = orgsQuery.data; + const searchOrgsLoading = orgsQuery.isLoading; + const searchedUsersData = usersQuery.data; + const searchUsersLoading = usersQuery.isLoading; // Initialize hotkeys (only active when command bar is open) useCommandBarHotkeys({ @@ -310,7 +312,7 @@ const CommandBar = () => { return { type: "org" as const, data: org, score }; }); - return [...userResults, ...orgResults] + return [...orgResults, ...userResults] .sort((a, b) => a.score - b.score) .slice(0, 15); } @@ -498,10 +500,40 @@ const CommandBar = () => { const userResults = sortedResults.filter((r) => r.type === "user"); const orgResults = sortedResults.filter((r) => r.type === "org"); + // Wait for orgs to load before showing anything so first org gets auto-selected + const waitingForOrgs = showResults && searchOrgsLoading; + return ( <> - {showResults && ( + {showResults && !waitingForOrgs && ( <> + {orgResults.length > 0 && ( + + {orgResults.map((result) => { + const org = result.data as Org; + const firstUser = org.users?.[0]; + if (!firstUser) return null; + + return ( + } + title={org.name} + subtext={org.slug} + onSelect={async () => { + try { + await impersonateUser(firstUser.id); + closeDialog(); + } catch (error) { + console.error("Failed to impersonate user:", error); + } + }} + /> + ); + })} + + )} + {userResults.length > 0 && ( {userResults.map((result) => { @@ -530,33 +562,6 @@ const CommandBar = () => { )} - {orgResults.length > 0 && ( - - {orgResults.map((result) => { - const org = result.data as Org; - const firstUser = org.users?.[0]; - if (!firstUser) return null; - - return ( - } - title={org.name} - subtext={org.slug} - onSelect={async () => { - try { - await impersonateUser(firstUser.id); - closeDialog(); - } catch (error) { - console.error("Failed to impersonate user:", error); - } - }} - /> - ); - })} - - )} - {isLoading && sortedResults.length === 0 && (
{[...Array(2)].map((_, i) => ( diff --git a/vite/src/views/command-bar/useCommandBarHotkeys.ts b/vite/src/views/command-bar/useCommandBarHotkeys.ts index 3465d1c4f..42658faad 100644 --- a/vite/src/views/command-bar/useCommandBarHotkeys.ts +++ b/vite/src/views/command-bar/useCommandBarHotkeys.ts @@ -5,7 +5,7 @@ import { useListOrganizations } from "@/lib/auth-client"; import { useEnv } from "@/utils/envUtils"; import { navigateTo } from "@/utils/genUtils"; import { useAdmin } from "@/views/admin/hooks/useAdmin"; -import { handleEnvChange } from "@/views/main-sidebar/EnvDropdown"; +import { useEnvChange } from "@/views/main-sidebar/EnvDropdown"; interface UseCommandBarHotkeysProps { /** Whether the command bar is open */ @@ -32,6 +32,7 @@ export const useCommandBarHotkeys = ({ }: UseCommandBarHotkeysProps) => { const navigate = useNavigate(); const env = useEnv(); + const handleEnvChange = useEnvChange(); const { data: orgs, isPending: isLoadingOrgs } = useListOrganizations(); const { isAdmin } = useAdmin(); diff --git a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx index fae017e5f..d23cbf9d8 100644 --- a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx +++ b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx @@ -49,8 +49,6 @@ export const AnalyticsView = () => { queryLoading, error, bcExclusionFlag, - topEventsLoading, - topEvents, groupBy, truncated, } = useAnalyticsData({ hasCleared }); @@ -159,7 +157,6 @@ export const AnalyticsView = () => { // Show empty state if no actual analytics events (check rawEvents and totalRows) const hasNoData = !rawQueryLoading && - !topEventsLoading && (!rawEvents || !rawEvents.data || rawEvents.data.length === 0) && totalRows === 0; @@ -220,7 +217,6 @@ export const AnalyticsView = () => { setTotalPages, totalRows, setTotalRows, - topEvents, propertyKeys, groupFilter, setGroupFilter, @@ -236,7 +232,7 @@ export const AnalyticsView = () => {
- {(queryLoading || topEventsLoading) && ( + {queryLoading && (

Loading chart {customerId ? `for ${customerId}` : ""} diff --git a/vite/src/views/customers/customer/analytics/components/SelectFeatureDropdown.tsx b/vite/src/views/customers/customer/analytics/components/SelectFeatureDropdown.tsx index 18f9e34ec..dbce7d15a 100644 --- a/vite/src/views/customers/customer/analytics/components/SelectFeatureDropdown.tsx +++ b/vite/src/views/customers/customer/analytics/components/SelectFeatureDropdown.tsx @@ -1,7 +1,7 @@ import type { Feature } from "@autumn/shared"; -import { FeatureType, FeatureUsageType } from "@autumn/shared"; +import { FeatureType } from "@autumn/shared"; import { CaretDownIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { Button } from "@/components/v2/buttons/Button"; @@ -12,18 +12,57 @@ import { DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; import { cn } from "@/lib/utils"; import { useAnalyticsContext } from "../AnalyticsContext"; -import { - eventNameBelongsToFeature, - getAllEventNames, -} from "../utils/getAllEventNames"; +import { useEventNames } from "../hooks/useEventNames"; const MAX_NUM_SELECTED = 10; +type EventOption = { + eventName: string; + eventCount: number; + linkedFeatures: Feature[]; + selected: boolean; +}; + +/** Gets all metered features and credit systems linked to this event name. + * First checks event_names array, then falls back to matching by feature ID. */ +const getFeaturesForEventName = ( + eventName: string, + features: Feature[], +): Feature[] => { + // First, find features that have this event name in their event_names array + const byEventName = features.filter( + (feature) => + (feature.type === FeatureType.Metered || + feature.type === FeatureType.CreditSystem) && + feature.event_names?.includes(eventName), + ); + + if (byEventName.length > 0) { + return byEventName; + } + + // Fallback: if the "event name" is actually a feature ID, find that feature + const byFeatureId = features.filter( + (feature) => + (feature.type === FeatureType.Metered || + feature.type === FeatureType.CreditSystem) && + feature.id === eventName, + ); + + return byFeatureId; +}; + +/** Formats the feature label for display using feature name */ +const formatFeatureLabel = (linkedFeatures: Feature[]): string | null => { + if (linkedFeatures.length === 0) return null; + if (linkedFeatures.length === 1) return linkedFeatures[0].name; + return `${linkedFeatures[0].name} + ${linkedFeatures.length - 1} more`; +}; + export const SelectFeatureDropdown = ({ classNames, }: { @@ -32,6 +71,7 @@ export const SelectFeatureDropdown = ({ }; }) => { const { features, setHasCleared } = useAnalyticsContext(); + const { eventNames: eventNamesData } = useEventNames(); const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(""); @@ -39,24 +79,41 @@ export const SelectFeatureDropdown = ({ const navigate = useNavigate(); const location = useLocation(); - // Get all event names - const allEventNames = getAllEventNames({ features }); - - // Read current values from query parameters - const currentFeatureIds = - searchParams.get("feature_ids")?.split(",").filter(Boolean) || []; + // Read current selected event names from query parameters const currentEventNames = searchParams.get("event_names")?.split(",").filter(Boolean) || []; + // Build event options from useEventNames data, enriched with feature info + const eventOptions: EventOption[] = useMemo(() => { + return eventNamesData.map((item) => ({ + eventName: item.event_name, + eventCount: item.event_count, + linkedFeatures: getFeaturesForEventName(item.event_name, features), + selected: currentEventNames.includes(item.event_name), + })); + }, [eventNamesData, features, currentEventNames]); + + // Filter options based on search (search both event name and feature ids) + const filteredOptions = useMemo(() => { + if (!searchValue) return eventOptions; + const lowerSearch = searchValue.toLowerCase(); + return eventOptions.filter( + (option) => + option.eventName.toLowerCase().includes(lowerSearch) || + option.linkedFeatures.some( + (f) => + f.id.toLowerCase().includes(lowerSearch) || + f.name.toLowerCase().includes(lowerSearch), + ), + ); + }, [eventOptions, searchValue]); + // Helper function to update query parameters - const updateQueryParams = (featureIds: string[], eventNames: string[]) => { + const updateQueryParams = (eventNames: string[]) => { const params = new URLSearchParams(location.search); - if (featureIds.length > 0) { - params.set("feature_ids", featureIds.join(",")); - } else { - params.delete("feature_ids"); - } + // Clear feature_ids since we're now only using event_names + params.delete("feature_ids"); if (eventNames.length > 0) { params.set("event_names", eventNames.join(",")); @@ -67,94 +124,28 @@ export const SelectFeatureDropdown = ({ navigate(`${location.pathname}?${params.toString()}`); }; - const numSelected = currentFeatureIds.length + currentEventNames.length; + const numSelected = currentEventNames.length; - // Create combined options for search - const featureOptions = features - .filter( - (feature: Feature) => - feature.type === FeatureType.Metered && - feature.config.usage_type === FeatureUsageType.Single, - ) - .map((feature: Feature) => ({ - type: "feature" as const, - id: feature.id, - name: feature.name, - selected: currentFeatureIds.includes(feature.id), - })); - - const eventOptions = allEventNames - .filter((eventName: string) => - eventNameBelongsToFeature({ eventName, features }), - ) - .map((eventName: string) => ({ - type: "event" as const, - id: eventName, - name: eventName, - selected: currentEventNames.includes(eventName), - })); - - const allOptions = [...featureOptions, ...eventOptions]; - - // Filter options based on search - const filteredOptions = allOptions.filter((option) => - option.name.toLowerCase().includes(searchValue.toLowerCase()), - ); - - const filteredFeatures = filteredOptions.filter( - (option) => option.type === "feature", - ); - const filteredEvents = filteredOptions.filter( - (option) => option.type === "event", - ); - - const handleToggleItem = (option: (typeof allOptions)[0]) => { - if (option.type === "feature") { - if (option.selected) { - updateQueryParams( - currentFeatureIds.filter((id: string) => id !== option.id), - currentEventNames, - ); - } else { - if (numSelected === MAX_NUM_SELECTED) { - toast.error( - `You can only select up to ${MAX_NUM_SELECTED} events/features`, - ); - } else { - updateQueryParams( - [...currentFeatureIds, option.id], - currentEventNames, - ); - } - } + const handleToggleItem = (option: EventOption) => { + if (option.selected) { + updateQueryParams( + currentEventNames.filter((name) => name !== option.eventName), + ); } else { - if (option.selected) { - updateQueryParams( - currentFeatureIds, - currentEventNames.filter((name: string) => name !== option.id), - ); + if (numSelected >= MAX_NUM_SELECTED) { + toast.error(`You can only select up to ${MAX_NUM_SELECTED} events`); } else { - if (numSelected === MAX_NUM_SELECTED) { - toast.error( - `You can only select up to ${MAX_NUM_SELECTED} events/features`, - ); - } else { - updateQueryParams(currentFeatureIds, [ - ...currentEventNames, - option.id, - ]); - } + updateQueryParams([...currentEventNames, option.eventName]); } } }; const handleClear = () => { - updateQueryParams([], []); + updateQueryParams([]); setHasCleared(true); }; - const hasNoResults = - filteredFeatures.length === 0 && filteredEvents.length === 0; + const hasNoResults = filteredOptions.length === 0; return ( @@ -166,16 +157,16 @@ export const SelectFeatureDropdown = ({ iconOrientation="right" className={cn(classNames?.trigger, open && "btn-secondary-active")} > - {numSelected > 0 ? `${numSelected} Selected` : "Default Features"} + {numSelected > 0 ? `${numSelected} Selected` : "Select Events"} - + {/* Search input */}

setSearchValue(e.target.value)} onKeyDown={(e) => e.stopPropagation()} @@ -186,49 +177,37 @@ export const SelectFeatureDropdown = ({
{hasNoResults ? (
- No results found. + No events found.
) : ( - <> - {filteredFeatures.length > 0 && ( - - - Features - - {filteredFeatures.map((option) => ( - handleToggleItem(option)} - onSelect={(e) => e.preventDefault()} - > - {option.name} - - ))} - - )} - - {filteredEvents.length > 0 && ( - <> - {filteredFeatures.length > 0 && } - - - Events - - {filteredEvents.map((option, index) => ( - handleToggleItem(option)} - onSelect={(e) => e.preventDefault()} - > - {option.name} - - ))} - - - )} - + + + Events + + {filteredOptions.map((option) => { + const featureLabel = formatFeatureLabel(option.linkedFeatures); + return ( + handleToggleItem(option)} + onSelect={(e) => e.preventDefault()} + className="pl-2" + > +
+ + {option.eventName} + + {featureLabel && ( + + ({featureLabel}) + + )} +
+
+ ); + })} +
)}
diff --git a/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx b/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx index 7f9c4bbdc..052cfb672 100644 --- a/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx +++ b/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx @@ -6,7 +6,6 @@ import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useAxiosSWR, usePostSWR } from "@/services/useAxiosSwr"; import { useEnv } from "@/utils/envUtils"; import { useEventNames } from "./useEventNames"; -import { useTopEventNames } from "./useTopEventNames"; /** Gets the user's IANA timezone (e.g., "America/New_York") */ const getUserTimezone = (): string => { @@ -33,7 +32,6 @@ export const useAnalyticsData = ({ const groupBy = searchParams.get("group_by"); const binSize = searchParams.get("bin_size"); - const { topEvents, isLoading: topEventsLoading } = useTopEventNames(); const { eventNames: cachedEventNames } = useEventNames(); // Get user's timezone - memoized since it won't change during session @@ -102,10 +100,8 @@ export const useAnalyticsData = ({ featuresLoading, queryLoading, events: data?.events, - topEvents: data?.topEvents, error: error?.code === ErrCode.ClickHouseDisabled ? null : error, bcExclusionFlag: data?.bcExclusionFlag ?? false, - topEventsLoading, groupBy, truncated: data?.truncated ?? false, }; diff --git a/vite/src/views/customers/customer/hooks/useCusEventsQuery.tsx b/vite/src/views/customers/customer/hooks/useCusEventsQuery.tsx index 0fe43db61..41b9b3d3b 100644 --- a/vite/src/views/customers/customer/hooks/useCusEventsQuery.tsx +++ b/vite/src/views/customers/customer/hooks/useCusEventsQuery.tsx @@ -2,21 +2,32 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { useAxiosInstance } from "@/services/useAxiosInstance"; -export const useCusEventsQuery = () => { +type IntervalType = "7d" | "30d" | "90d"; + +export const useCusEventsQuery = ({ + interval, + limit, +}: { + interval?: IntervalType; + limit?: number; +} = {}) => { const axiosInstance = useAxiosInstance(); const { customer_id } = useParams(); const fetcher = async () => { - // console.log("Fetching events for customer:", customer_id); - const { data } = await axiosInstance.get( - `/customers/${customer_id}/events`, - ); - // console.log("Events:", data); + const params = new URLSearchParams(); + if (interval) params.set("interval", interval); + if (limit) params.set("limit", limit.toString()); + + const queryString = params.toString(); + const url = `/customers/${customer_id}/events${queryString ? `?${queryString}` : ""}`; + + const { data } = await axiosInstance.get(url); return data; }; const { data, isLoading, error } = useQuery({ - queryKey: ["customer_events", customer_id], + queryKey: ["customer_events", customer_id, interval, limit], queryFn: fetcher, }); diff --git a/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx b/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx index 143613262..78486eed6 100644 --- a/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx +++ b/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx @@ -5,6 +5,7 @@ import { AttachPlanSection, AttachPreviewSection, AttachProductSelection, + AttachUpdatesSection, useAttachFormContext, } from "@/components/forms/attach-v2"; import { InlinePlanEditor } from "@/components/v2/inline-custom-plan-editor/InlinePlanEditor"; @@ -35,7 +36,8 @@ function SheetContent() { const { entityId } = useEntity(); const { customer } = useCusQuery(); - const entities = (customer as FullCustomer)?.entities || []; + const fullCustomer = customer as FullCustomer | null; + const entities = fullCustomer?.entities || []; const fullEntity = entities.find( (e: Entity) => e.id === entityId || e.internal_id === entityId, ); @@ -73,6 +75,7 @@ function SheetContent() { {hasProductSelected && ( <> + diff --git a/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx b/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx index 4334c9669..6e89fd044 100644 --- a/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx +++ b/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx @@ -22,6 +22,7 @@ import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet"; import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; import { getBackendErr, notNullish } from "@/utils/genUtils"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; @@ -281,6 +282,17 @@ export function BalanceEditSheet() { } /> )} + + {selectedCusEnt.expires_at && ( + + )}
diff --git a/vite/src/views/customers2/components/sheets/BalanceSelectionSheet.tsx b/vite/src/views/customers2/components/sheets/BalanceSelectionSheet.tsx index 4c0b3fc71..3a1ff18ef 100644 --- a/vite/src/views/customers2/components/sheets/BalanceSelectionSheet.tsx +++ b/vite/src/views/customers2/components/sheets/BalanceSelectionSheet.tsx @@ -153,8 +153,22 @@ export function BalanceSelectionSheet() { )} + {cusEnt.expires_at && ( + + )}
diff --git a/vite/src/views/customers2/components/sheets/SubscriptionCancelSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionCancelSheet.tsx index 8a1a78bdc..a9d38f2af 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionCancelSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionCancelSheet.tsx @@ -31,6 +31,8 @@ function SheetContent() { const isDefault = customerProduct.product.is_default; const isScheduled = customerProduct.status === CusProductStatus.Scheduled; + const { valid: isFreeOrOneOff } = cp(customerProduct).free().or.oneOff(); + const isFreeDefault = isDefault && isFreeOrOneOff; return ( @@ -57,7 +59,7 @@ function SheetContent() {
)} - {isDefault && ( + {isFreeDefault && (
This is the default plan. Cancelling it means this customer will diff --git a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx index 8c2df1a35..a66073397 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx @@ -10,6 +10,7 @@ import { import { ArrowSquareOutIcon, CalendarBlankIcon, + CreditCardIcon, CubeIcon, GitBranchIcon, HashIcon, @@ -23,6 +24,7 @@ import { format } from "date-fns"; import { useEffect } from "react"; import { useNavigate } from "react-router"; import { Button } from "@/components/v2/buttons/Button"; +import { MiniCopyButton } from "@/components/v2/buttons/CopyButton"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { InfoRow } from "@/components/v2/InfoRow"; import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet"; @@ -211,8 +213,8 @@ export function SubscriptionDetailSheet() { )} {/* Product Information */} -
-
+
+
} label="Plan" @@ -236,6 +238,32 @@ export function SubscriptionDetailSheet() { value={cusProduct.quantity.toString()} /> )} + {cusProduct.subscription_ids?.length > 0 && ( +
+
+ +
+
+
+ Sub ID +
+
+ +
+
+ } + className="shrink-0" + > + View Stripe + +
+ )}
@@ -259,64 +287,53 @@ export function SubscriptionDetailSheet() { )} {/* Status & Dates */} -
-
+
+ } + label="Status" + value={ + + } + /> + + } + label="Started" + value={formatDate(cusProduct.starts_at)} + /> + + {cusProduct.trial_ends_at && ( } - label="Status" - value={ - - } + icon={} + label="Trial Ends" + value={formatDate(cusProduct.trial_ends_at)} /> + )} + {cusProduct.canceled_at && ( } - label="Started" - value={formatDate(cusProduct.starts_at)} + icon={} + label="Canceled" + value={formatDate(cusProduct.canceled_at)} /> + )} - {cusProduct.trial_ends_at && ( - } - label="Trial Ends" - value={formatDate(cusProduct.trial_ends_at)} - /> - )} - - {cusProduct.canceled_at && ( - } - label="Canceled" - value={formatDate(cusProduct.canceled_at)} - /> - )} - - {cusProduct.ended_at && ( - } - label="Ended" - value={formatDate(cusProduct.ended_at)} - /> - )} -
- {cusProduct.subscription_ids?.length > 0 && ( - } - > - View Stripe - + {cusProduct.ended_at && ( + } + label="Ended" + value={formatDate(cusProduct.ended_at)} + /> )}
@@ -338,7 +355,7 @@ export function SubscriptionDetailSheet() { setSheet({ type: "subscription-uncancel", itemId }) } > - Uncancel Subscription + Manage Cancellation ) : ( - - - {entities.map((e: Entity) => { - const isSelected = entityId === e.id || entityId === e.internal_id; - const entityValue = e.id || e.internal_id; - return ( -
handleValueChange(entityValue)} - > -
- {e.name && ( - - {e.name} - - )} - - {entityValue} - -
- -
- ); - })} -
- - ); - }; + const getEntityValue = (entity: Entity) => entity.id || entity.internal_id; + const getEntityLabel = (entity: Entity) => + entity.name || entity.id || entity.internal_id; return ( <>
- {renderEntitySelector()} + + entity ? ( + + {entity.name || entity.id || entity.internal_id} + + ) : ( + Select entity + ) + } + renderOption={(entity, isSelected) => { + const entityValue = getEntityValue(entity); + return ( + <> +
+ {entity.name && ( + {entity.name} + )} + + {entityValue} + +
+ {isSelected && } + + ); + }} + /> {entityId && (
{entityId ? (
- {/* {selectedEntity.name && ( -
- {selectedEntity.name} -
- )} */} { @@ -14,7 +15,7 @@ const getUserTimezone = (): string => { export const useCustomerTimeseriesEvents = ({ interval = "30d", - eventNames = [], + eventNames: providedEventNames, }: { interval?: "24h" | "7d" | "30d" | "90d"; eventNames?: string[]; @@ -24,6 +25,12 @@ export const useCustomerTimeseriesEvents = ({ // Get user's timezone - memoized since it won't change during session const timezone = useMemo(() => getUserTimezone(), []); + // Use cached event names if none provided + const { eventNames: cachedEventNames } = useEventNames(); + const eventNames = providedEventNames?.length + ? providedEventNames + : cachedEventNames.slice(0, 3).map((e) => e.event_name); + const { data, isLoading, error } = usePostSWR({ url: `/query/events`, data: { diff --git a/vite/src/views/main-sidebar/EnvDropdown.tsx b/vite/src/views/main-sidebar/EnvDropdown.tsx index 9861ea541..91c73417e 100644 --- a/vite/src/views/main-sidebar/EnvDropdown.tsx +++ b/vite/src/views/main-sidebar/EnvDropdown.tsx @@ -2,8 +2,10 @@ "use client"; import { AppEnv } from "@autumn/shared"; +import { useQueryClient } from "@tanstack/react-query"; import { Check } from "lucide-react"; import { useState } from "react"; +import { useNavigate } from "react-router"; import { DropdownMenu, @@ -14,22 +16,36 @@ import { cn } from "@/lib/utils"; import { envToPath } from "@/utils/genUtils"; import { ExpandedEnvTrigger } from "./env-dropdown/ExpandedEnvTrigger"; -export const handleEnvChange = async (env: AppEnv, reset?: boolean) => { - const newPath = envToPath(env, location.pathname); - if (newPath && !reset) { - const params = new URLSearchParams(location.search); - const tab = params.get("tab"); - const url = tab ? `${newPath}?tab=${encodeURIComponent(tab)}` : newPath; - window.location.href = url; - } else { - window.location.href = - env === AppEnv.Sandbox ? "/sandbox/products" : "/products"; - } +export const useEnvChange = () => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const handleEnvChange = (targetEnv: AppEnv, reset?: boolean) => { + // Clear all cached query data so it refetches for the new env + queryClient.clear(); + + // Calculate the new path + const newPath = envToPath(targetEnv, location.pathname); + + if (newPath && !reset) { + const params = new URLSearchParams(location.search); + const tab = params.get("tab"); + const url = tab ? `${newPath}?tab=${encodeURIComponent(tab)}` : newPath; + navigate(url); + } else { + navigate( + targetEnv === AppEnv.Sandbox ? "/sandbox/products" : "/products", + ); + } + }; + + return handleEnvChange; }; export const EnvDropdown = ({ env }: { env: AppEnv }) => { const [isHovered, setIsHovered] = useState(false); const [open, setOpen] = useState(false); + const handleEnvChange = useEnvChange(); return (
void; } -function ImageUploadButton({ disabled }: { disabled?: boolean }) { - const attachments = usePromptInputAttachments(); - - return ( - attachments.openFileDialog()} - disabled={disabled} - title="Add image" - > - - - ); -} - -function AttachmentsHeader() { - const attachments = usePromptInputAttachments(); - - if (!attachments.files.length) { - return null; - } - - return ( - - - {(attachment) => } - - - ); -} - -// Type for the build_pricing tool part -type BuildPricingToolPart = { - type: "tool-build_pricing"; - toolCallId: string; - toolName: "build_pricing"; - state: - | "input-streaming" - | "input-available" - | "output-available" - | "output-error"; - input?: AgentPricingConfig; - output?: unknown; - errorText?: string; -}; - -interface PreviewOrg { - apiKey: string; - orgId: string; - orgSlug: string; -} - export function AIChatView({ onBack }: AIChatViewProps) { - const [input, setInput] = useState(""); - const [hasStartedChat, setHasStartedChat] = useState(false); - const [pricingConfig, setPricingConfig] = useState( - null, - ); - const [jsonSheetConfig, setJsonSheetConfig] = - useState(null); - const [previewOrg, setPreviewOrg] = useState(null); - const [isPreviewSyncing, setIsPreviewSyncing] = useState(false); - const previewSetupRef = useRef | null>(null); - const axiosInstance = useAxiosInstance(); - - // Session ID for PostHog AI tracing - groups all messages in a conversation - const reactId = useId(); - const chatSessionIdRef = useRef(`pricing-chat-${reactId}-${Date.now()}`); - - /** Setup the preview org (called once, memoized) */ - const setupPreviewOrg = useCallback(async (): Promise => { - // If already setting up, return the existing promise - if (previewSetupRef.current) { - return previewSetupRef.current; - } - - const setupPromise = (async () => { - try { - console.log("[Preview] Setting up preview org..."); - const response = await fetch( - `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/preview/setup`, - { - method: "POST", - credentials: "include", - headers: { - "x-client-type": "dashboard", - "Content-Type": "application/json", - }, - }, - ); - - if (!response.ok) { - const error = await response.json(); - console.error("[Preview] Setup failed:", error); - return null; - } - - const data = await response.json(); - const org: PreviewOrg = { - apiKey: data.api_key, - orgId: data.org_id, - orgSlug: data.org_slug, - }; - console.log("[Preview] Setup complete:", { - orgId: org.orgId, - orgSlug: org.orgSlug, - }); - setPreviewOrg(org); - return org; - } catch (error) { - console.error("[Preview] Setup error:", error); - return null; - } - })(); - - previewSetupRef.current = setupPromise; - return setupPromise; - }, []); - - /** Sync pricing config to the preview org */ - const syncPreviewPricing = useCallback( - async (config: AgentPricingConfig) => { - // Ensure preview org is set up - let org = previewOrg; - if (!org) { - org = await setupPreviewOrg(); - if (!org) { - console.error("[Preview] Cannot sync - preview org not available"); - return; - } - } - - setIsPreviewSyncing(true); - try { - console.log("[Preview] Syncing pricing config..."); - console.log("[Preview] Features:", config.features.length); - console.log("[Preview] Products:", config.products.length); - - const response = await axiosInstance.post( - "/pricing-agent/preview/sync", - { - features: config.features, - products: config.products, - }, - ); - - console.log("[Preview] Sync complete:", response.data); - } catch (error) { - console.error("[Preview] Sync error:", error); - } finally { - setIsPreviewSyncing(false); - } - }, - [axiosInstance, previewOrg, setupPreviewOrg], - ); - - const { messages, sendMessage, status, addToolOutput } = useChat({ - transport: new DefaultChatTransport({ - api: `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/chat`, - credentials: "include", - headers: { - "x-client-type": "dashboard", - }, - body: { - sessionId: chatSessionIdRef.current, - }, - }), - - // Auto-submit when all tool results are available (for multi-step if needed) - sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, - - // Handle client-side tool execution - onToolCall: async ({ toolCall }) => { - // Check for dynamic tools first - if (toolCall.dynamic) { - return; - } - - if (toolCall.toolName === "build_pricing") { - const config = toolCall.input as AgentPricingConfig; - - // Update the pricing preview - setPricingConfig(config); - - // Sync to preview org (fire and forget) - syncPreviewPricing(config); - - // Return the tool result (no await to avoid deadlocks) - addToolOutput({ - tool: "build_pricing", - toolCallId: toolCall.toolCallId, - output: { - success: true, - productsCount: config.products.length, - featuresCount: config.features.length, - }, - }); - } - }, - }); - - const handleSubmit = (message: PromptInputMessage) => { - if ( - (!message.text.trim() && message.files.length === 0) || - status !== "ready" - ) - return; - - setHasStartedChat(true); - sendMessage({ - text: message.text, - files: message.files, - }); - setInput(""); - }; + const { + messages, + input, + setInput, + isLoading, + hasStartedChat, + pricingConfig, + previewOrg, + isPreviewSyncing, + jsonSheetConfig, + setJsonSheetConfig, + handleSubmit, + handleStartNewChat, + } = usePricingAgentChat(); const handleSelectTemplate = ({ prompt }: { prompt: string }) => { setInput(prompt); }; - const isLoading = status === "streaming" || status === "submitted"; - - const handleStartNewChat = () => { - setHasStartedChat(false); - setInput(""); - setPricingConfig(null); - }; - return ( {} }}>
@@ -389,124 +163,16 @@ export function AIChatView({ onBack }: AIChatViewProps) { transition={{ duration: 0.4, delay: 0.3 }} className="w-1/3 flex flex-col pt-14" > - - - {messages.map((message) => ( - - - {message.parts.map((part, partIndex) => { - switch (part.type) { - case "text": - return ( - - {part.text} - - ); - - case "file": { - const isImage = - part.mediaType?.startsWith("image/"); - if (!isImage || !part.url) return null; - return ( -
- {part.filename -
- ); - } - - case "tool-build_pricing": { - const toolPart = part as BuildPricingToolPart; - return ( -
- {toolPart.state === "input-streaming" || - toolPart.state === "input-available" ? ( - - Building pricing configuration - - ) : toolPart.state === "output-error" ? ( - - Error generating pricing - - ) : ( - <> - - Generated{" "} - {toolPart.input?.products.length ?? - 0}{" "} - product(s) and{" "} - {toolPart.input?.features.length ?? - 0}{" "} - feature(s) - - - - )} -
- ); - } - - default: - return null; - } - })} -
-
- ))} - {isLoading && - messages.length > 0 && - messages[messages.length - 1]?.role === "user" && ( -
- Planning next steps -
- )} -
-
- -
- - - - setInput(e.target.value)} - placeholder="Describe your app's pricing" - disabled={isLoading} - /> - - - - - - -
+ {/* Right: Pricing Preview */} @@ -529,7 +195,7 @@ export function AIChatView({ onBack }: AIChatViewProps) { size="sm" onClick={handleStartNewChat} > - Clear chat + New chat diff --git a/vite/src/views/onboarding4/CopyPlansButton.tsx b/vite/src/views/onboarding4/CopyPlansButton.tsx index e4679f1a4..cd1bf930a 100644 --- a/vite/src/views/onboarding4/CopyPlansButton.tsx +++ b/vite/src/views/onboarding4/CopyPlansButton.tsx @@ -1,3 +1,4 @@ +import type { AgentPricingConfig } from "@autumn/shared"; import { useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; @@ -15,7 +16,6 @@ import { useOrg } from "@/hooks/common/useOrg"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr, pushPage } from "@/utils/genUtils"; -import type { AgentPricingConfig } from "./pricingAgentUtils"; interface CopyPlansButtonProps { pricingConfig: AgentPricingConfig; diff --git a/vite/src/views/onboarding4/OnboardingGuide.tsx b/vite/src/views/onboarding4/OnboardingGuide.tsx index 5ab58311f..3e76f2da0 100644 --- a/vite/src/views/onboarding4/OnboardingGuide.tsx +++ b/vite/src/views/onboarding4/OnboardingGuide.tsx @@ -105,7 +105,7 @@ function StepCard({ animate={{ flex: isActive ? 4 : 1 }} transition={{ duration: 0.3, ease: "easeInOut" }} className={cn( - "relative border dark:border-none rounded-xl bg-card cursor-pointer h-29 overflow-hidden", + "relative dark:border-none rounded-xl bg-card cursor-pointer h-29 overflow-hidden", isActive ? "" : "hover:border-primary/20 hover:bg-interactive-secondary-hover", @@ -277,7 +277,7 @@ export function OnboardingGuide() { } return ( -
+
{/* Dismiss button */}
diff --git a/vite/src/views/onboarding4/PricingConfigSheet.tsx b/vite/src/views/onboarding4/PricingConfigSheet.tsx index 73b5e3c34..196b8c555 100644 --- a/vite/src/views/onboarding4/PricingConfigSheet.tsx +++ b/vite/src/views/onboarding4/PricingConfigSheet.tsx @@ -1,3 +1,4 @@ +import type { AgentPricingConfig } from "@autumn/shared"; import { CodeGroup, CodeGroupCode, @@ -11,7 +12,6 @@ import { SheetHeader, SheetTitle, } from "@/components/v2/sheets/Sheet"; -import type { AgentPricingConfig } from "./pricingAgentUtils"; interface PricingConfigSheetProps { open: boolean; diff --git a/vite/src/views/onboarding4/PricingPreview.tsx b/vite/src/views/onboarding4/PricingPreview.tsx index 84c1146a0..f41938fd3 100644 --- a/vite/src/views/onboarding4/PricingPreview.tsx +++ b/vite/src/views/onboarding4/PricingPreview.tsx @@ -1,8 +1,12 @@ +import type { AgentPricingConfig } from "@autumn/shared"; import type { ReactNode } from "react"; +import { GroupedPlanCards } from "./preview/GroupedPlanCards"; import { PreviewCreditSchemaCard } from "./preview/PreviewCreditSchemaCard"; -import { PreviewPlanCard } from "./preview/PreviewPlanCard"; -import { transformToPreviewProducts } from "./preview/previewTypes"; -import type { AgentPricingConfig } from "./pricingAgentUtils"; +import { + getChangedFeatureIds, + getChangedProductIds, + transformToPreviewProducts, +} from "./preview/previewTypes"; interface PreviewOrg { apiKey: string; @@ -12,6 +16,7 @@ interface PreviewOrg { interface PricingPreviewProps { config: AgentPricingConfig | null; + initialConfig?: AgentPricingConfig | null; previewOrg: PreviewOrg | null; isSyncing: boolean; headerActions?: ReactNode; @@ -19,6 +24,7 @@ interface PricingPreviewProps { export function PricingPreview({ config, + initialConfig, previewOrg, isSyncing, headerActions, @@ -32,6 +38,18 @@ export function PricingPreview({ }) : []; + // Compute which products have changed from initial config + const changedProductIds = getChangedProductIds({ + initialConfig: initialConfig ?? null, + currentConfig: config, + }); + + // Compute which features have changed from initial config + const changedFeatureIds = getChangedFeatureIds({ + initialConfig: initialConfig ?? null, + currentConfig: config, + }); + // Find credit system features to display their schemas const creditSystemFeatures = hasProducts ? config.features.filter( @@ -45,7 +63,7 @@ export function PricingPreview({ return (
{/* Mac window header */} -
+
@@ -57,31 +75,29 @@ export function PricingPreview({
{headerActions && ( -
{headerActions}
+
+ {headerActions} +
)}
{/* Content area with dotted grid background */}
{hasProducts && ( - <> -
- {previewProducts.map((product) => ( - - ))} -
+
+ {/* Credit system schema cards */} {creditSystemFeatures.length > 0 && ( @@ -91,11 +107,12 @@ export function PricingPreview({ key={creditFeature.id} creditFeature={creditFeature} allFeatures={config.features} + isChanged={changedFeatureIds.has(creditFeature.id)} /> ))}
)} - +
)}
diff --git a/vite/src/views/onboarding4/components/ChatInputComponents.tsx b/vite/src/views/onboarding4/components/ChatInputComponents.tsx new file mode 100644 index 000000000..77ef33ad9 --- /dev/null +++ b/vite/src/views/onboarding4/components/ChatInputComponents.tsx @@ -0,0 +1,71 @@ +import type { AgentPricingConfig } from "@autumn/shared"; +import { ImageIcon } from "lucide-react"; +import { + PromptInputAttachment, + PromptInputAttachments, + PromptInputButton, + PromptInputHeader, + usePromptInputAttachments, +} from "@/components/ai-elements/prompt-input"; + +/** + * Type for the build_pricing tool part returned by the AI + */ +export type BuildPricingToolPart = { + type: "tool-build_pricing"; + toolCallId: string; + toolName: "build_pricing"; + state: + | "input-streaming" + | "input-available" + | "output-available" + | "output-error"; + input?: AgentPricingConfig; + output?: unknown; + errorText?: string; +}; + +/** + * Preview org configuration for syncing pricing + */ +export interface PreviewOrg { + apiKey: string; + orgId: string; + orgSlug: string; +} + +/** + * Button to open file dialog for image uploads in the chat input + */ +export function ImageUploadButton({ disabled }: { disabled?: boolean }) { + const attachments = usePromptInputAttachments(); + + return ( + attachments.openFileDialog()} + disabled={disabled} + title="Add image" + > + + + ); +} + +/** + * Header showing attached files in the prompt input + */ +export function AttachmentsHeader() { + const attachments = usePromptInputAttachments(); + + if (!attachments.files.length) { + return null; + } + + return ( + + + {(attachment) => } + + + ); +} diff --git a/vite/src/views/onboarding4/components/PricingChatPanel.tsx b/vite/src/views/onboarding4/components/PricingChatPanel.tsx new file mode 100644 index 000000000..6099d51bd --- /dev/null +++ b/vite/src/views/onboarding4/components/PricingChatPanel.tsx @@ -0,0 +1,169 @@ +import type { AgentPricingConfig } from "@autumn/shared"; +import type { UIMessage } from "ai"; +import { + Conversation, + ConversationContent, +} from "@/components/ai-elements/conversation"; +import { + Message, + MessageContent, + MessageResponse, +} from "@/components/ai-elements/message"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + type PromptInputMessage, + PromptInputSubmit, + PromptInputTextarea, +} from "@/components/ai-elements/prompt-input"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { Button } from "@/components/v2/buttons/Button"; +import { cn } from "@/lib/utils"; +import { + AttachmentsHeader, + type BuildPricingToolPart, + ImageUploadButton, +} from "./ChatInputComponents"; + +interface PricingChatPanelProps { + messages: UIMessage[]; + input: string; + onInputChange: (value: string) => void; + onSubmit: (message: PromptInputMessage) => void; + isLoading: boolean; + onViewJson?: (config: AgentPricingConfig) => void; + placeholder?: string; + className?: string; + inputClassName?: string; +} + +/** + * Reusable chat panel for pricing agent conversations. + * Renders messages list and prompt input. + */ +export function PricingChatPanel({ + messages, + input, + onInputChange, + onSubmit, + isLoading, + onViewJson, + placeholder = "Describe your app's pricing", + className, + inputClassName, +}: PricingChatPanelProps) { + return ( +
+ + + {messages.map((message) => ( + + + {message.parts.map((part, partIndex) => { + switch (part.type) { + case "text": + return ( + + {part.text} + + ); + + case "file": { + const isImage = part.mediaType?.startsWith("image/"); + if (!isImage || !part.url) return null; + return ( +
+ {part.filename +
+ ); + } + + case "tool-build_pricing": { + const toolPart = part as BuildPricingToolPart; + return ( +
+ {toolPart.state === "input-streaming" || + toolPart.state === "input-available" ? ( + + Building pricing configuration + + ) : toolPart.state === "output-error" ? ( + + Error generating pricing + + ) : ( + <> + + Generated {toolPart.input?.products.length ?? 0}{" "} + product(s) and{" "} + {toolPart.input?.features.length ?? 0}{" "} + feature(s) + + {onViewJson && toolPart.input && ( + + )} + + )} +
+ ); + } + + default: + return null; + } + })} +
+
+ ))} + {isLoading && + messages.length > 0 && + messages[messages.length - 1]?.role === "user" && ( +
+ Planning next steps +
+ )} +
+
+ +
+ + + + onInputChange(e.target.value)} + placeholder={placeholder} + disabled={isLoading} + /> + + + + + + +
+
+ ); +} diff --git a/vite/src/views/onboarding4/components/TemplatePrompts.tsx b/vite/src/views/onboarding4/components/TemplatePrompts.tsx index 163ea31b7..1c51aa120 100644 --- a/vite/src/views/onboarding4/components/TemplatePrompts.tsx +++ b/vite/src/views/onboarding4/components/TemplatePrompts.tsx @@ -7,7 +7,7 @@ interface TemplatePromptsProps { export function TemplatePrompts({ onSelectTemplate }: TemplatePromptsProps) { return ( -
+
{/* Header with decorative lines */}
diff --git a/vite/src/views/onboarding4/hooks/usePricingAgentChat.ts b/vite/src/views/onboarding4/hooks/usePricingAgentChat.ts new file mode 100644 index 000000000..f388a6e99 --- /dev/null +++ b/vite/src/views/onboarding4/hooks/usePricingAgentChat.ts @@ -0,0 +1,244 @@ +import { useChat } from "@ai-sdk/react"; +import type { AgentPricingConfig } from "@autumn/shared"; +import { + DefaultChatTransport, + lastAssistantMessageIsCompleteWithToolCalls, +} from "ai"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import type { PreviewOrg } from "../components/ChatInputComponents"; + +export interface UsePricingAgentChatOptions { + initialConfig?: AgentPricingConfig | null; +} + +export function usePricingAgentChat(options?: UsePricingAgentChatOptions) { + const [input, setInput] = useState(""); + const [hasStartedChat, setHasStartedChat] = useState( + options?.initialConfig != null, + ); + const [pricingConfig, setPricingConfig] = useState( + options?.initialConfig ?? null, + ); + const [jsonSheetConfig, setJsonSheetConfig] = + useState(null); + const [previewOrg, setPreviewOrg] = useState(null); + const [isPreviewSyncing, setIsPreviewSyncing] = useState(false); + const previewSetupRef = useRef | null>(null); + const syncInProgressRef = useRef(false); + const initialSyncDoneRef = useRef(false); + const axiosInstance = useAxiosInstance(); + + // Session ID for PostHog AI tracing - groups all messages in a conversation + const reactId = useId(); + const chatSessionIdRef = useRef(`pricing-chat-${reactId}-${Date.now()}`); + + /** Setup the preview org (called once, memoized) */ + const setupPreviewOrg = useCallback(async (): Promise => { + // If already setting up, return the existing promise + if (previewSetupRef.current) { + return previewSetupRef.current; + } + + const setupPromise = (async () => { + try { + console.log("[Preview] Setting up preview org..."); + const response = await fetch( + `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/preview/setup`, + { + method: "POST", + credentials: "include", + headers: { + "x-client-type": "dashboard", + "Content-Type": "application/json", + }, + }, + ); + + if (!response.ok) { + const error = await response.json(); + console.error("[Preview] Setup failed:", error); + return null; + } + + const data = await response.json(); + const org: PreviewOrg = { + apiKey: data.api_key, + orgId: data.org_id, + orgSlug: data.org_slug, + }; + console.log("[Preview] Setup complete:", { + orgId: org.orgId, + orgSlug: org.orgSlug, + }); + setPreviewOrg(org); + return org; + } catch (error) { + console.error("[Preview] Setup error:", error); + return null; + } + })(); + + previewSetupRef.current = setupPromise; + return setupPromise; + }, []); + + /** Sync pricing config to the preview org */ + const syncPreviewPricing = useCallback( + async (config: AgentPricingConfig) => { + // Prevent concurrent syncs + if (syncInProgressRef.current) { + console.log("[Preview] Sync already in progress, skipping..."); + return; + } + + // Ensure preview org is set up + let org = previewOrg; + if (!org) { + org = await setupPreviewOrg(); + if (!org) { + console.error("[Preview] Cannot sync - preview org not available"); + return; + } + } + + syncInProgressRef.current = true; + setIsPreviewSyncing(true); + try { + console.log("[Preview] Syncing pricing config..."); + console.log("[Preview] Features:", config.features.length); + console.log("[Preview] Products:", config.products.length); + + const response = await axiosInstance.post( + "/pricing-agent/preview/sync", + { + features: config.features, + products: config.products, + }, + ); + + console.log("[Preview] Sync complete:", response.data); + } catch (error) { + console.error("[Preview] Sync error:", error); + } finally { + syncInProgressRef.current = false; + setIsPreviewSyncing(false); + } + }, + [axiosInstance, previewOrg, setupPreviewOrg], + ); + + // Sync initial config on mount if provided + useEffect(() => { + if (options?.initialConfig && !initialSyncDoneRef.current) { + initialSyncDoneRef.current = true; + setupPreviewOrg().then((org) => { + if (org) { + syncPreviewPricing(options.initialConfig as AgentPricingConfig); + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { messages, sendMessage, status, addToolOutput, setMessages } = useChat({ + transport: new DefaultChatTransport({ + api: `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/chat`, + credentials: "include", + headers: { + "x-client-type": "dashboard", + }, + body: { + sessionId: chatSessionIdRef.current, + initialConfig: options?.initialConfig ?? null, + }, + }), + + // Auto-submit when all tool results are available (for multi-step if needed) + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + + // Handle client-side tool execution + onToolCall: async ({ toolCall }) => { + // Check for dynamic tools first + if (toolCall.dynamic) { + return; + } + + if (toolCall.toolName === "build_pricing") { + const config = toolCall.input as AgentPricingConfig; + + // Update the pricing preview + setPricingConfig(config); + + // Sync to preview org (fire and forget) + syncPreviewPricing(config); + + // Return the tool result (no await to avoid deadlocks) + addToolOutput({ + tool: "build_pricing", + toolCallId: toolCall.toolCallId, + output: { + success: true, + productsCount: config.products.length, + featuresCount: config.features.length, + }, + }); + } + }, + }); + + const handleSubmit = useCallback( + (message: PromptInputMessage) => { + if ( + (!message.text.trim() && message.files.length === 0) || + status !== "ready" + ) + return; + + setHasStartedChat(true); + sendMessage({ + text: message.text, + files: message.files, + }); + setInput(""); + }, + [status, sendMessage], + ); + + const handleStartNewChat = useCallback(() => { + setHasStartedChat(false); + setInput(""); + setPricingConfig(null); + setMessages([]); + }, [setMessages]); + + const isLoading = status === "streaming" || status === "submitted"; + + return { + // Chat state + messages, + input, + setInput, + status, + isLoading, + hasStartedChat, + setHasStartedChat, + + // Pricing config + pricingConfig, + setPricingConfig, + + // Preview org + previewOrg, + isPreviewSyncing, + + // JSON sheet + jsonSheetConfig, + setJsonSheetConfig, + + // Actions + handleSubmit, + handleStartNewChat, + }; +} diff --git a/vite/src/views/onboarding4/preview/GroupedPlanCards.tsx b/vite/src/views/onboarding4/preview/GroupedPlanCards.tsx new file mode 100644 index 000000000..0c9620d32 --- /dev/null +++ b/vite/src/views/onboarding4/preview/GroupedPlanCards.tsx @@ -0,0 +1,49 @@ +import { SectionTag } from "@/components/v2/badges/SectionTag"; +import { PreviewPlanCard } from "./PreviewPlanCard"; +import { groupPreviewProducts, type PreviewProduct } from "./previewTypes"; + +interface GroupedPlanCardsProps { + products: PreviewProduct[]; + previewApiKey?: string; + isSyncing: boolean; + changedProductIds: Set; +} + +export function GroupedPlanCards({ + products, + previewApiKey, + isSyncing, + changedProductIds, +}: GroupedPlanCardsProps) { + const { subscriptions, addOnSubscriptions, oneTimePlans } = + groupPreviewProducts(products); + + const renderSection = (title: string, items: PreviewProduct[]) => { + if (items.length === 0) return null; + + return ( +
+ {title} +
+ {items.map((product) => ( + + ))} +
+
+ ); + }; + + return ( + <> + {renderSection("Subscriptions", subscriptions)} + {renderSection("Add-on subscriptions", addOnSubscriptions)} + {renderSection("One-off purchases", oneTimePlans)} + + ); +} diff --git a/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx b/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx index 333d09e1a..0da4db1f2 100644 --- a/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx +++ b/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx @@ -6,7 +6,7 @@ type CheckoutState = "idle" | "waiting_for_sync" | "creating_checkout"; interface PreviewCheckoutButtonProps { productId: string; - previewApiKey: string; + previewApiKey?: string; isSyncing: boolean; } @@ -18,6 +18,7 @@ export function PreviewCheckoutButton({ const [checkoutState, setCheckoutState] = useState("idle"); const createCheckout = useCallback(async () => { + if (!previewApiKey) return; setCheckoutState("creating_checkout"); try { console.log( @@ -70,6 +71,7 @@ export function PreviewCheckoutButton({ }, [checkoutState, isSyncing, createCheckout]); const handleClick = () => { + if (!previewApiKey) return; if (isSyncing) { // Wait for sync to complete setCheckoutState("waiting_for_sync"); @@ -80,6 +82,7 @@ export function PreviewCheckoutButton({ }; const isLoading = checkoutState !== "idle"; + const isDisabled = isLoading || !previewApiKey; const getButtonText = () => { switch (checkoutState) { @@ -98,7 +101,7 @@ export function PreviewCheckoutButton({ size="sm" className="w-full mt-auto" onClick={handleClick} - disabled={isLoading} + disabled={isDisabled} > {isLoading ? ( <> diff --git a/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx b/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx index 0db09dd2c..3aefbe3c2 100644 --- a/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx +++ b/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx @@ -1,3 +1,4 @@ +import type { AgentFeature } from "@autumn/shared"; import { Coins } from "lucide-react"; import { Card, @@ -5,11 +6,12 @@ import { CardHeader, CardTitle, } from "@/components/v2/cards/Card"; -import type { AgentFeature } from "../pricingAgentUtils"; +import { cn } from "@/lib/utils"; interface PreviewCreditSchemaCardProps { creditFeature: AgentFeature; allFeatures: AgentFeature[]; + isChanged?: boolean; } /** @@ -18,6 +20,7 @@ interface PreviewCreditSchemaCardProps { export function PreviewCreditSchemaCard({ creditFeature, allFeatures, + isChanged = false, }: PreviewCreditSchemaCardProps) { const creditSchema = creditFeature.credit_schema; @@ -30,15 +33,20 @@ export function PreviewCreditSchemaCard({ const creditSingular = creditFeature.display?.singular ?? "credit"; return ( - +
-
- +
+
{creditDisplayName}
-

Credit cost per action

@@ -55,7 +63,7 @@ export function PreviewCreditSchemaCard({ return (
{targetName} diff --git a/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx b/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx index 48e7c69c8..d523bae5a 100644 --- a/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx +++ b/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx @@ -1,10 +1,6 @@ import { - BatteryHighIcon, BoxArrowDownIcon, - CoinsIcon, MoneyWavyIcon, - TicketIcon, - ToggleRightIcon, WalletIcon, } from "@phosphor-icons/react"; import type React from "react"; @@ -13,7 +9,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/v2/tooltips/Tooltip"; -import type { AgentFeature } from "../pricingAgentUtils"; +import { getFeatureIconConfig } from "@/views/products/features/utils/getFeatureIcon"; import type { PreviewProductItem } from "./previewTypes"; interface PreviewFeatureIconProps { @@ -22,53 +18,8 @@ interface PreviewFeatureIconProps { size?: number; } -type FeatureTypeKey = AgentFeature["type"]; type BillingType = "included" | "prepaid" | "paid"; -/** - * Get icon for feature type (left position) - */ -function getFeatureTypeIcon({ - featureType, - size, -}: { - featureType: FeatureTypeKey; - size: number; -}): { icon: React.ReactNode; color: string; label: string } { - const weight = "duotone"; - - switch (featureType) { - case "boolean": - case "static": - return { - icon: , - color: "text-red-500", - label: "Boolean", - }; - - case "credit_system": - return { - icon: , - color: "text-pink-500", - label: "Credit System", - }; - - case "continuous_use": - return { - icon: , - color: "text-blue-500", - label: "Non-consumable", - }; - - default: - return { - icon: , - color: "text-fuchsia-500", - label: "Consumable", - }; - } -} - /** * Determine billing type from item properties */ @@ -130,7 +81,7 @@ export function PreviewFeatureIcon({ }: PreviewFeatureIconProps) { const iconData = position === "left" - ? getFeatureTypeIcon({ featureType: item.featureType, size }) + ? getFeatureIconConfig(item.featureType, null, size) : getBillingTypeIcon({ billingType: getBillingType(item), size }); return ( diff --git a/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx b/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx index 71c93f39e..cba574dad 100644 --- a/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx +++ b/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx @@ -11,15 +11,13 @@ function DotIcon() { } export function PreviewFeatureRow({ item }: PreviewFeatureRowProps) { - // Use column layout only if item has both pricing AND included usage - const hasPricing = item.price != null && item.price > 0; - const hasIncludedUsage = - item.includedUsage != null && - (item.includedUsage === "inf" || item.includedUsage > 0); - const useColumnLayout = hasPricing && hasIncludedUsage; + // Use column layout when secondary text starts with "then" (indicates pricing with included usage) + // This handles both flat price and tiered pricing scenarios + const useColumnLayout = + item.display.secondaryText?.startsWith("then") ?? false; return ( -
+
@@ -35,7 +33,7 @@ export function PreviewFeatureRow({ item }: PreviewFeatureRowProps) {
{item.display.secondaryText && ( - + {item.display.secondaryText} )} diff --git a/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx b/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx index 301485ce8..4478b6125 100644 --- a/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx +++ b/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx @@ -4,6 +4,7 @@ import { CardFooter, CardHeader, } from "@/components/v2/cards/Card"; +import { cn } from "@/lib/utils"; import { PreviewCheckoutButton } from "./PreviewCheckoutButton"; import { PreviewFeatureRow } from "./PreviewFeatureRow"; import { PreviewPlanHeader } from "./PreviewPlanHeader"; @@ -13,15 +14,23 @@ interface PreviewPlanCardProps { product: PreviewProduct; previewApiKey?: string; isSyncing: boolean; + isChanged?: boolean; } export function PreviewPlanCard({ product, previewApiKey, isSyncing, + isChanged = false, }: PreviewPlanCardProps) { return ( - + @@ -39,7 +48,7 @@ export function PreviewPlanCard({ )} - {previewApiKey && product.basePrice.type !== "free" && ( + {product.basePrice.type !== "free" && ( {/* Name row with badges */}
- + {product.name} ({ + ...item, + interval: item.interval ?? null, + })); + const isOneOff = isOneOffProductV2({ items: normalizedItems }); + // Transform feature items const featureItems = (product.items ?? []) .filter((item) => item.feature_id) @@ -81,6 +89,7 @@ export function transformToPreviewProducts({ name: product.name, isAddOn: product.is_add_on, isDefault: product.is_default, + isOneOff, basePrice, items: featureItems, freeTrial: product.free_trial @@ -93,88 +102,6 @@ export function transformToPreviewProducts({ }); } -/** - * Convert AgentProduct to ProductV2 for use with shared utilities - */ -function agentProductToProductV2(product: AgentProduct): ProductV2 { - const items: ProductItem[] = (product.items ?? []).map( - agentItemToProductItem, - ); - - 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, // Free trial display handled separately in PreviewProduct - items, - created_at: Date.now(), - }; -} - -/** - * Map AgentFeature type string to FeatureType enum - */ -function mapFeatureType(agentType: AgentFeature["type"]): FeatureType { - switch (agentType) { - case "boolean": - case "static": - return FeatureType.Boolean; - case "credit_system": - return FeatureType.CreditSystem; - default: - return FeatureType.Metered; - } -} - -/** - * Convert AgentFeature to shared Feature type - */ -function agentFeatureToFeature(agentFeature: AgentFeature): Feature { - 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: mapFeatureType(agentFeature.type), - config: null, - display: agentFeature.display - ? { - singular: agentFeature.display.singular, - plural: agentFeature.display.plural, - } - : undefined, - archived: false, - event_names: [], - }; -} - -/** - * Convert AgentProductItem to shared ProductItem type - */ -function agentItemToProductItem(item: AgentProductItem): ProductItem { - return { - feature_id: item.feature_id, - included_usage: - item.included_usage === "inf" - ? Infinite - : (item.included_usage ?? undefined), - interval: item.interval as ProductItem["interval"], - price: item.price, - billing_units: item.billing_units, - usage_model: item.usage_model as ProductItem["usage_model"], - tiers: - item.price != null ? [{ to: Infinite, amount: item.price }] : undefined, - }; -} - function transformToPreviewItem({ item, features, @@ -217,3 +144,248 @@ function transformToPreviewItem({ }, }; } + +// ============ CHANGE DETECTION ============ + +/** Normalize falsy values for comparison (undefined, null, false all become false) */ +function normalizeBool(val: boolean | undefined | null): boolean { + return val === true; +} + +/** Normalize optional strings (undefined, null, and empty string all become null) */ +function normalizeStr(val: string | undefined | null): string | null { + return val || null; +} + +/** Normalize optional numbers */ +function normalizeNum( + val: number | "inf" | undefined | null, +): number | "inf" | null { + return val ?? null; +} + +function areItemsEqual( + items1: AgentProductItem[], + items2: AgentProductItem[], +): boolean { + if (items1.length !== items2.length) return false; + + return items1.every((item1, index) => { + const item2 = items2[index]; + return ( + normalizeStr(item1.feature_id) === normalizeStr(item2.feature_id) && + normalizeNum(item1.included_usage) === + normalizeNum(item2.included_usage) && + normalizeStr(item1.interval) === normalizeStr(item2.interval) && + normalizeNum(item1.price) === normalizeNum(item2.price) && + normalizeStr(item1.usage_model) === normalizeStr(item2.usage_model) && + normalizeNum(item1.billing_units) === normalizeNum(item2.billing_units) && + JSON.stringify(item1.tiers ?? null) === + JSON.stringify(item2.tiers ?? null) + ); + }); +} + +function areProductsEqual( + product1: AgentProduct, + product2: AgentProduct, +): boolean { + if ( + product1.name !== product2.name || + normalizeBool(product1.is_add_on) !== normalizeBool(product2.is_add_on) || + normalizeBool(product1.is_default) !== normalizeBool(product2.is_default) || + normalizeStr(product1.group) !== normalizeStr(product2.group) + ) { + return false; + } + + // Compare free trial (both falsy = equal) + const ft1 = product1.free_trial; + const ft2 = product2.free_trial; + const hasFt1 = ft1 != null; + const hasFt2 = ft2 != null; + if (hasFt1 !== hasFt2) return false; + if (ft1 && ft2) { + if (ft1.length !== ft2.length || ft1.duration !== ft2.duration) { + return false; + } + } + + // Compare items + const items1 = product1.items ?? []; + const items2 = product2.items ?? []; + return areItemsEqual(items1, items2); +} + +/** Returns a Set of product IDs that have changed from the initial config */ +export function getChangedProductIds({ + initialConfig, + currentConfig, +}: { + initialConfig: AgentPricingConfig | null; + currentConfig: AgentPricingConfig | null; +}): Set { + const changedIds = new Set(); + + if (!initialConfig || !currentConfig) { + return changedIds; + } + + const initialProductMap = new Map( + initialConfig.products.map((p) => [p.id, p]), + ); + + for (const currentProduct of currentConfig.products) { + const initialProduct = initialProductMap.get(currentProduct.id); + + // New product (wasn't in initial config) + if (!initialProduct) { + changedIds.add(currentProduct.id); + continue; + } + + // Existing product - check if changed + if (!areProductsEqual(initialProduct, currentProduct)) { + changedIds.add(currentProduct.id); + } + } + + return changedIds; +} + +function areFreeTrialsEqual( + ft1: AgentProduct["free_trial"], + ft2: AgentProduct["free_trial"], +): boolean { + if (!ft1 && !ft2) return true; + if (!ft1 || !ft2) return false; + return ft1.length === ft2.length && ft1.duration === ft2.duration; +} + +/** Returns product IDs that will create new versions (existing + customers + billing changes) */ +export function getVersionedProductIds({ + initialConfig, + currentConfig, + productCounts, +}: { + initialConfig: AgentPricingConfig | null; + currentConfig: AgentPricingConfig | null; + productCounts: Record; +}): string[] { + if (!initialConfig || !currentConfig) return []; + + const initialProductMap = new Map( + initialConfig.products.map((p) => [p.id, p]), + ); + + return currentConfig.products + .filter((currentProduct) => { + const initialProduct = initialProductMap.get(currentProduct.id); + if (!initialProduct) return false; + + const customerCount = productCounts[currentProduct.id]?.all ?? 0; + if (customerCount === 0) return false; + + const itemsChanged = !areItemsEqual( + initialProduct.items ?? [], + currentProduct.items ?? [], + ); + const freeTrialChanged = !areFreeTrialsEqual( + initialProduct.free_trial, + currentProduct.free_trial, + ); + + return itemsChanged || freeTrialChanged; + }) + .map((product) => product.id); +} + +/** Check if two credit system features are equal */ +function areFeaturesEqual( + feature1: AgentFeature, + feature2: AgentFeature, +): boolean { + if (feature1.name !== feature2.name || feature1.type !== feature2.type) { + return false; + } + + // Compare display - only if BOTH features have display defined + // The display field is cosmetic (UI labels) and doesn't affect billing. + // If one has display and the other doesn't, it's not a meaningful change. + const d1 = feature1.display; + const d2 = feature2.display; + if (d1 && d2) { + if (d1.singular !== d2.singular || d1.plural !== d2.plural) { + return false; + } + } + + // Compare credit schema + const cs1 = feature1.credit_schema ?? []; + const cs2 = feature2.credit_schema ?? []; + if (cs1.length !== cs2.length) return false; + return cs1.every((s1, index) => { + const s2 = cs2[index]; + return ( + s1.metered_feature_id === s2.metered_feature_id && + s1.credit_cost === s2.credit_cost + ); + }); +} + +/** Returns a Set of feature IDs that have changed from the initial config */ +export function getChangedFeatureIds({ + initialConfig, + currentConfig, +}: { + initialConfig: AgentPricingConfig | null; + currentConfig: AgentPricingConfig | null; +}): Set { + const changedIds = new Set(); + + if (!initialConfig || !currentConfig) { + return changedIds; + } + + const initialFeatureMap = new Map( + initialConfig.features.map((f) => [f.id, f]), + ); + + for (const currentFeature of currentConfig.features) { + const initialFeature = initialFeatureMap.get(currentFeature.id); + + // New feature (wasn't in initial config) + if (!initialFeature) { + changedIds.add(currentFeature.id); + continue; + } + + // Existing feature - check if changed + if (!areFeaturesEqual(initialFeature, currentFeature)) { + changedIds.add(currentFeature.id); + } + } + + return changedIds; +} + +// ============ PRODUCT GROUPING ============ + +export interface GroupedPreviewProducts { + subscriptions: PreviewProduct[]; + addOnSubscriptions: PreviewProduct[]; + oneTimePlans: PreviewProduct[]; +} + +/** Groups products into subscriptions, add-on subscriptions, and one-time plans (mirrors ProductListTable logic) */ +export function groupPreviewProducts( + products: PreviewProduct[], +): GroupedPreviewProducts { + const oneTimePlans = products.filter((p) => p.isOneOff); + const recurringPlans = products.filter((p) => !p.isOneOff); + + const subscriptions = recurringPlans.filter((p) => !p.isAddOn); + const addOnSubscriptions = recurringPlans.filter((p) => p.isAddOn); + + return { subscriptions, addOnSubscriptions, oneTimePlans }; +} diff --git a/vite/src/views/onboarding4/pricingAgentUtils.ts b/vite/src/views/onboarding4/pricingAgentUtils.ts deleted file mode 100644 index f677ae754..000000000 --- a/vite/src/views/onboarding4/pricingAgentUtils.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { PricingTier } from "./templateConfigs"; - -/** - * Types for the pricing config returned by the AI agent's build_pricing tool - */ -export interface AgentFeature { - id: string; - name?: string | null; - type: - | "static" - | "boolean" - | "single_use" - | "continuous_use" - | "credit_system"; - 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; - 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[]; -} - -/** - * Transform an AgentPricingConfig (from the AI) into PricingTier[] (for the UI) - */ -function transformConfigToTiers({ - config, - features, -}: { - config: AgentPricingConfig; - features: AgentFeature[]; -}): PricingTier[] { - return config.products.map((product) => { - // Find the base price (item without feature_id, or first priced item) - const basePrice = product.items?.find( - (item) => !item.feature_id && item.price != null, - ); - const fixedPrice = basePrice?.price ?? 0; - const interval = basePrice?.interval ?? "month"; - - // Determine price display - let priceDisplay: string; - if ( - fixedPrice === 0 && - !product.items?.some((i) => i.price && i.price > 0) - ) { - priceDisplay = "Free"; - } else if (fixedPrice > 0) { - priceDisplay = `$${fixedPrice}`; - } else { - // Usage-based only - priceDisplay = "Usage-based"; - } - - // Build feature list for the card - const featureList: string[] = []; - - for (const item of product.items ?? []) { - if (item.feature_id) { - const feature = features.find((f) => f.id === item.feature_id); - const featureName = - feature?.name ?? feature?.display?.plural ?? item.feature_id; - - if (item.included_usage === "inf") { - featureList.push(`Unlimited ${featureName}`); - } else if (item.included_usage != null && item.included_usage > 0) { - featureList.push( - `${item.included_usage.toLocaleString()} ${featureName}`, - ); - } else if (item.price != null && item.price > 0) { - featureList.push( - `${featureName} at $${item.price}${item.billing_units ? `/${item.billing_units}` : "/unit"}`, - ); - } - } else if (item.price != null && item.price > 0 && !basePrice) { - // It's a standalone price item - featureList.push(`$${item.price}/${item.interval ?? "month"} base`); - } - } - - // Add free trial info if present - if (product.free_trial) { - featureList.push( - `${product.free_trial.length} ${product.free_trial.duration} free trial`, - ); - } - - // Determine if this tier should be highlighted - // Typically the "Pro" or middle tier, or explicitly named - const isHighlighted = - product.name.toLowerCase().includes("pro") || - product.name.toLowerCase().includes("plus") || - product.name.toLowerCase().includes("premium"); - - return { - name: product.name, - price: priceDisplay, - interval: fixedPrice > 0 ? interval : undefined, - description: product.is_add_on ? "Add-on" : undefined, - features: featureList.length > 0 ? featureList : ["Basic features"], - highlighted: isHighlighted, - }; - }); -} diff --git a/vite/src/views/onboarding4/utils/convertToAgentConfig.ts b/vite/src/views/onboarding4/utils/convertToAgentConfig.ts new file mode 100644 index 000000000..6eb90e70e --- /dev/null +++ b/vite/src/views/onboarding4/utils/convertToAgentConfig.ts @@ -0,0 +1,5 @@ +/** + * Re-export convertToAgentConfig from shared for backwards compatibility. + * The actual implementation now lives in @autumn/shared. + */ +export { convertToAgentConfig } from "@autumn/shared"; diff --git a/vite/src/views/products/plan/components/DeletePlanDialog.tsx b/vite/src/views/products/plan/components/DeletePlanDialog.tsx index 61f8a2ae0..e51207afc 100644 --- a/vite/src/views/products/plan/components/DeletePlanDialog.tsx +++ b/vite/src/views/products/plan/components/DeletePlanDialog.tsx @@ -31,13 +31,11 @@ export const DeletePlanDialog = ({ open, setOpen, onDeleteSuccess, - dropdownOpen = false, }: { propProduct?: ProductV2; open: boolean; setOpen: (open: boolean) => void; onDeleteSuccess?: () => Promise; - dropdownOpen?: boolean; }) => { const axiosInstance = useAxiosInstance(); const storeProduct = useProductStore((s) => s.product); @@ -52,13 +50,12 @@ export const DeletePlanDialog = ({ const [loading, setLoading] = useState(false); const [deleteAllVersions, setDeleteAllVersions] = useState(false); const { invalidate: invalidateProducts } = useProductsQuery(); - const { invalidate: invalidateProduct, refetch: refetchProduct } = - useProductQuery(); + const { invalidate: invalidateProduct } = useProductQuery(); const { data: productInfo, isLoading } = useGeneralQuery({ url: `/products/${product.id}/info`, queryKey: ["productInfo", product.id], - enabled: dropdownOpen || open, + enabled: open, method: "GET", }); @@ -71,15 +68,17 @@ export const DeletePlanDialog = ({ deleteAllVersions, ); - await Promise.all([invalidateProducts(), invalidateProduct()]); + // Close dialog and show toast immediately + setOpen(false); + toast.success("Plan deleted successfully"); - // Call onDeleteSuccess callback if provided (for onboarding) + // Invalidate in background (don't await - let table update async) + Promise.all([invalidateProducts(), invalidateProduct()]); + + // Call onDeleteSuccess callback if provided (for navigation) if (onDeleteSuccess) { await onDeleteSuccess(); } - - setOpen(false); - toast.success("Plan deleted successfully"); } catch (error: unknown) { toast.error(getBackendErr(error as AxiosError, "Error deleting plan")); } finally { @@ -94,12 +93,16 @@ export const DeletePlanDialog = ({ archived: true, }); + // Close dialog and show toast immediately + setOpen(false); + toast.success(`${product.name} archived successfully`); + + // Invalidate in background (don't await) + Promise.all([invalidateProducts(), invalidateProduct()]); + if (onDeleteSuccess) { await onDeleteSuccess(); } - toast.success(`${product.name} archived successfully`); - setOpen(false); - await Promise.all([invalidateProducts(), invalidateProduct()]); } catch (error) { toast.error(getBackendErr(error, "Error archiving plan")); } finally { @@ -114,12 +117,16 @@ export const DeletePlanDialog = ({ archived: false, }); + // Close dialog and show toast immediately + setOpen(false); + toast.success(`${product.name} unarchived successfully`); + + // Invalidate in background (don't await) + Promise.all([invalidateProducts(), invalidateProduct()]); + if (onDeleteSuccess) { await onDeleteSuccess(); } - await Promise.all([invalidateProducts(), invalidateProduct()]); - toast.success(`${product.name} unarchived successfully`); - setOpen(false); } catch (error) { toast.error(getBackendErr(error, "Error unarchiving plan")); } finally { diff --git a/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx b/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx index a5257c64f..aaa7c7ce4 100644 --- a/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx +++ b/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx @@ -7,11 +7,7 @@ import { ProductItemInterval, productV2ToBasePrice, } from "@autumn/shared"; -import { - ArrowsClockwiseIcon, - BarcodeIcon, - CheckCircleIcon, -} from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon, CheckCircleIcon } from "@phosphor-icons/react"; import { GroupedTabButton } from "@/components/v2/buttons/GroupedTabButton"; import { FormLabel } from "@/components/v2/form/FormLabel"; import { @@ -23,6 +19,8 @@ import { InputGroupAddon, InputGroupInput, } from "@/components/v2/inputs/InputGroup"; +import { AreaRadioGroupItem } from "@/components/v2/radio-groups/AreaRadioGroupItem"; +import { RadioGroup } from "@/components/v2/radio-groups/RadioGroup"; import { SheetSection } from "@/components/v2/sheets/InlineSheet"; import { useOrg } from "@/hooks/common/useOrg"; import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; @@ -106,95 +104,148 @@ export const BasePriceSection = ({ const disabled = nullish(basePrice); + // Determine if we're in "usage" (per unit only) mode or "base price" mode + const isPerUnitOnly = basePriceType === "usage"; + + // Get the billing type (recurring or one-off) - default to recurring + const billingType = + basePriceType === "usage" + ? "recurring" + : basePriceType === "one-off" + ? "one-off" + : "recurring"; + + const handleBillingTypeChange = (value: string) => { + // Only change billing type if we're in base price mode + if (isPerUnitOnly) return; + + // Check if there's already a price item + const hasPriceItem = product.items.some((item) => isPriceItem(item)); + + if (!hasPriceItem) { + // Recreate the price item with default price of 0 + const newPriceItem: ProductItem = { + price: "" as unknown as number, + interval: value === "one-off" ? null : ProductItemInterval.Month, + interval_count: 1, + }; + + setProduct({ + ...product, + basePriceType: value as "recurring" | "one-off", + items: [...product.items, newPriceItem], + }); + return; + } + + // Update existing price item + setProduct({ + ...product, + basePriceType: value as "recurring" | "one-off", + items: product.items.map((item) => { + if (isPriceItem(item)) { + return { + ...item, + interval: value === "one-off" ? null : ProductItemInterval.Month, + }; + } + return item; + }), + }); + }; + + const handlePriceTypeChange = (value: string) => { + if (value === "usage") { + // Switch to per unit only - remove base price item + setProduct({ + ...product, + basePriceType: "usage", + items: product.items.filter((item) => !isPriceItem(item)), + }); + } else { + // Switch to base price - restore price item with current billing type + const hasPriceItem = product.items.some((item) => isPriceItem(item)); + + if (!hasPriceItem) { + const newPriceItem: ProductItem = { + price: "" as unknown as number, + interval: + billingType === "one-off" ? null : ProductItemInterval.Month, + interval_count: 1, + }; + + setProduct({ + ...product, + basePriceType: billingType as "recurring" | "one-off", + items: [...product.items, newPriceItem], + }); + } else { + setProduct({ + ...product, + basePriceType: billingType as "recurring" | "one-off", + }); + } + } + }; + return ( - -
-
- { - //if usage based, remove the base price item - if (value === "usage") { - setProduct({ - ...product, - basePriceType: "usage", - items: product.items.filter((item) => !isPriceItem(item)), - }); - return; - } - - // Check if there's already a price item - const hasPriceItem = product.items.some((item) => - isPriceItem(item), - ); - - if (!hasPriceItem) { - // Recreate the price item with default price of 0 - const newPriceItem: ProductItem = { - price: "" as unknown as number, - interval: - value === "one-off" ? null : ProductItemInterval.Month, - interval_count: 1, - }; - - setProduct({ - ...product, - basePriceType: value as "recurring" | "one-off" | "usage", - items: [...product.items, newPriceItem], - }); - return; - } - - // Update existing price item - setProduct({ - ...product, - basePriceType: value as "recurring" | "one-off" | "usage", - items: product.items.map((item) => { - if (isPriceItem(item)) { - return { - ...item, - interval: - value === "one-off" ? null : ProductItemInterval.Month, - }; - } - return item; - }), - }); - }} - options={[ - { - value: "recurring", - label: "Recurring", - icon: ( - - ), - }, - { - value: "one-off", - label: "One-off", - icon: ( - - ), - }, - { - value: "usage", - label: "Per unit only", - icon: , - }, - ]} - /> - {/*

{priceDescription}

*/} + +
+
+ + + +
-
- {basePriceType !== "usage" ? ( + + {isPerUnitOnly ? ( + + This plan has no base price. You can add per unit prices, such as + per "seat" or "credit", when adding features. + + ) : ( + <> +
+ + ), + }, + { + value: "one-off", + label: "One-off", + icon: ( + + ), + }, + ]} + /> +
Price @@ -225,24 +276,19 @@ export const BasePriceSection = ({
- {basePriceType === "recurring" && ( + {billingType === "recurring" && (
)}
- ) : ( - - This plan has no base price. You can add per unit prices, such as - per "seat" or "credit", when adding features. - - )} -
+ + )}
); diff --git a/vite/src/views/products/plan/components/plan-card/AddFeatureRow.tsx b/vite/src/views/products/plan/components/plan-card/AddFeatureRow.tsx index 9a2e42d4e..d3c836646 100644 --- a/vite/src/views/products/plan/components/plan-card/AddFeatureRow.tsx +++ b/vite/src/views/products/plan/components/plan-card/AddFeatureRow.tsx @@ -20,15 +20,7 @@ export const AddFeatureRow = ({ disabled }: AddFeatureRowProps) => { const item = useCurrentItem(); const handleAddFeatureClick = () => { - const addedFeatureIds = new Set( - product.items?.map((item) => item.feature_id).filter(Boolean) || [], - ); - - const availableFeatures = features.filter( - (feature) => !addedFeatureIds.has(feature.id), - ); - - if (availableFeatures.length === 0) { + if (features.length === 0) { setSheet({ type: "new-feature", itemId: "new" }); } else { setSheet({ type: "select-feature", itemId: "select" }); diff --git a/vite/src/views/products/product/utils/updateProduct.ts b/vite/src/views/products/product/utils/updateProduct.ts index 70f3912a8..64a311fbc 100644 --- a/vite/src/views/products/product/utils/updateProduct.ts +++ b/vite/src/views/products/product/utils/updateProduct.ts @@ -1,6 +1,6 @@ import { type FrontendProductItem, - type ProductV2, + type UpdateProductV2Params, UpdateProductV2ParamsSchema, } from "@autumn/shared"; import type { AxiosError, AxiosInstance } from "axios"; @@ -18,7 +18,7 @@ export const updateProduct = async ({ }: { axiosInstance: AxiosInstance; productId: string; - product: ProductV2; + product: UpdateProductV2Params; onSuccess: () => Promise; }) => { const validated = validateItemsBeforeSave( diff --git a/vite/src/views/products/products/ProductsPage.tsx b/vite/src/views/products/products/ProductsPage.tsx index 525b6e9bd..c5da10c95 100644 --- a/vite/src/views/products/products/ProductsPage.tsx +++ b/vite/src/views/products/products/ProductsPage.tsx @@ -1,13 +1,32 @@ +import { useState } from "react"; import { useClearQueryParams } from "@/hooks/common/useClearQueryParams"; +import { ProductsAIChatView } from "./components/ProductsAIChatView"; +import { ProductsPageHeader } from "./components/ProductsPageHeader"; +import { + type ProductsViewMode, + ProductsViewToggle, +} from "./components/ProductsViewToggle"; +import { ProductListCreateButton } from "./components/product-list/ProductListCreateButton"; +import { ProductListMenuButton } from "./components/product-list/ProductListMenuButton"; import { ProductListTable } from "./components/product-list/ProductListTable"; export const ProductsPage = () => { // Clean up onboarding-related query params after a delay useClearQueryParams({ queryParams: ["step", "product_id"] }); + const [viewMode, setViewMode] = useState("list"); + return (
- + {/* Shared header - always visible */} + + + + + + + {/* Conditional content */} + {viewMode === "list" ? : }
); }; diff --git a/vite/src/views/products/products/components/ConfirmBatchVersionDialog.tsx b/vite/src/views/products/products/components/ConfirmBatchVersionDialog.tsx new file mode 100644 index 000000000..dc5e2fdb2 --- /dev/null +++ b/vite/src/views/products/products/components/ConfirmBatchVersionDialog.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { Input } from "@/components/v2/inputs/Input"; + +const CONFIRM_TEXT = "confirm"; + +interface ConfirmBatchVersionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + productIds: string[]; + onConfirm: () => Promise; +} + +export function ConfirmBatchVersionDialog({ + open, + onOpenChange, + productIds, + onConfirm, +}: ConfirmBatchVersionDialogProps) { + const [confirmText, setConfirmText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const isValid = confirmText.trim() === CONFIRM_TEXT; + + const handleConfirm = async () => { + if (!isValid) return; + setIsLoading(true); + try { + await onConfirm(); + onOpenChange(false); + } finally { + setIsLoading(false); + setConfirmText(""); + } + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + setConfirmText(""); + } + onOpenChange(nextOpen); + }; + + return ( + + + + Create new versions? + +

+ The following plans have active customers and will create{" "} + new versions: +

+
    + {productIds.map((productId) => ( +
  • + {productId} +
  • + ))} +
+

+ Type {CONFIRM_TEXT} to continue. +

+ setConfirmText(e.target.value)} + type="text" + placeholder={CONFIRM_TEXT} + className="w-full" + /> +
+
+ + + + +
+
+ ); +} diff --git a/vite/src/views/products/products/components/ProductsAIChatView.tsx b/vite/src/views/products/products/components/ProductsAIChatView.tsx new file mode 100644 index 000000000..19bc2472f --- /dev/null +++ b/vite/src/views/products/products/components/ProductsAIChatView.tsx @@ -0,0 +1,150 @@ +import { useMemo } from "react"; +import { CompactPromptInput } from "@/components/ai-elements/CompactPromptInput"; +import { Button } from "@/components/v2/buttons/Button"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { PricingChatPanel } from "@/views/onboarding4/components/PricingChatPanel"; +import { usePricingAgentChat } from "@/views/onboarding4/hooks/usePricingAgentChat"; +import { PricingConfigSheet } from "@/views/onboarding4/PricingConfigSheet"; +import { PricingPreview } from "@/views/onboarding4/PricingPreview"; +import { convertToAgentConfig } from "@/views/onboarding4/utils/convertToAgentConfig"; +import { usePricingConfigSave } from "../hooks/usePricingConfigSave"; +import { ConfirmBatchVersionDialog } from "./ConfirmBatchVersionDialog"; + +/** + * AI chat view for the products page. + * Similar to the onboarding AIChatView but starts with existing plans loaded. + */ +export function ProductsAIChatView() { + const { products } = useProductsQuery(); + const { features } = useFeaturesQuery(); + + // Filter out archived products + const activeProducts = useMemo( + () => products.filter((p) => !p.archived), + [products], + ); + + // Convert existing products/features to initial config + const initialConfig = useMemo(() => { + if (activeProducts.length === 0 && features.length === 0) { + return null; + } + return convertToAgentConfig({ products: activeProducts, features }); + }, [activeProducts, features]); + + const { + messages, + input, + setInput, + isLoading, + pricingConfig, + previewOrg, + isPreviewSyncing, + jsonSheetConfig, + setJsonSheetConfig, + handleSubmit, + handleStartNewChat, + } = usePricingAgentChat({ initialConfig }); + + const { + isSaving, + confirmOpen, + setConfirmOpen, + versionedProductIds, + handleSave, + handleConfirmSave, + } = usePricingConfigSave({ initialConfig }); + + // Track if chat has started (has any messages) + const chatStarted = messages.length > 0; + + // Use the AI-generated config if available, otherwise fall back to initial + const displayConfig = pricingConfig ?? initialConfig; + + return ( +
+
+ {/* Left: Chat - width animates from 0 */} +
+ {chatStarted && ( + + )} +
+ + {/* Right: Pricing Preview - shrinks as chat expands */} +
+ 0 && + chatStarted && ( + <> + + + + ) + } + /> + + {/* Floating input when chat hasn't started */} + {!chatStarted && ( +
+
+ handleSubmit({ text: input, files: [] })} + placeholder="Describe changes to your pricing..." + isLoading={isLoading} + /> +
+
+ )} +
+
+ + !open && setJsonSheetConfig(null)} + config={jsonSheetConfig} + /> + +
+ ); +} diff --git a/vite/src/views/products/products/components/ProductsPageHeader.tsx b/vite/src/views/products/products/components/ProductsPageHeader.tsx new file mode 100644 index 000000000..ee23d092c --- /dev/null +++ b/vite/src/views/products/products/components/ProductsPageHeader.tsx @@ -0,0 +1,24 @@ +import { CubeIcon } from "@phosphor-icons/react"; +import type { ReactNode } from "react"; + +interface ProductsPageHeaderProps { + children?: ReactNode; +} + +/** + * Shared header for the Products/Plans page. + * Matches Table.Toolbar + Table.Heading styles. + */ +export function ProductsPageHeader({ children }: ProductsPageHeaderProps) { + return ( +
+
+
+ + Plans +
+
{children}
+
+
+ ); +} diff --git a/vite/src/views/products/products/components/ProductsViewToggle.tsx b/vite/src/views/products/products/components/ProductsViewToggle.tsx new file mode 100644 index 000000000..be1db6966 --- /dev/null +++ b/vite/src/views/products/products/components/ProductsViewToggle.tsx @@ -0,0 +1,66 @@ +import { ListBulletsIcon, SquareHalfIcon } from "@phosphor-icons/react"; +import { cn } from "@/lib/utils"; + +export type ProductsViewMode = "list" | "ai"; + +interface ProductsViewToggleProps { + value: ProductsViewMode; + onValueChange: (value: ProductsViewMode) => void; + className?: string; +} + +/** + * Icon toggle to switch between list view and AI chat view + */ +export function ProductsViewToggle({ + value, + onValueChange, + className, +}: ProductsViewToggleProps) { + const options: Array<{ + value: ProductsViewMode; + icon: React.ReactNode; + title: string; + }> = [ + { + value: "list", + icon: , + title: "List view", + }, + { + value: "ai", + icon: , + title: "AI assistant", + }, + ]; + + return ( +
+ {options.map((option, index) => { + const isActive = value === option.value; + const isFirst = index === 0; + const isLast = index === options.length - 1; + + return ( + + ); + })} +
+ ); +} diff --git a/vite/src/views/products/products/components/product-list/ProductListColumns.tsx b/vite/src/views/products/products/components/product-list/ProductListColumns.tsx index c44f84721..9cadb80d7 100644 --- a/vite/src/views/products/products/components/product-list/ProductListColumns.tsx +++ b/vite/src/views/products/products/components/product-list/ProductListColumns.tsx @@ -10,8 +10,10 @@ import { ProductListRowToolbar } from "./ProductListRowToolbar"; export const createProductListColumns = ({ showGroup = false, + onDeleteClick, }: { showGroup?: boolean; + onDeleteClick?: (product: ProductV2) => void; } = {}) => [ { size: 300, @@ -98,7 +100,10 @@ export const createProductListColumns = ({ className="flex justify-end w-full pr-2" onClick={(e) => e.stopPropagation()} > - +
); }, diff --git a/vite/src/views/products/products/components/product-list/ProductListRowToolbar.tsx b/vite/src/views/products/products/components/product-list/ProductListRowToolbar.tsx index 1cae3d570..b350a788b 100644 --- a/vite/src/views/products/products/components/product-list/ProductListRowToolbar.tsx +++ b/vite/src/views/products/products/components/product-list/ProductListRowToolbar.tsx @@ -17,14 +17,18 @@ import { DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { DeletePlanDialog } from "@/views/products/plan/components/DeletePlanDialog"; import { CopyProductDialog } from "../CopyProductDialog"; -export const ProductListRowToolbar = ({ product }: { product: ProductV2 }) => { +export const ProductListRowToolbar = ({ + product, + onDeleteClick, +}: { + product: ProductV2; + onDeleteClick?: (product: ProductV2) => void; +}) => { const [dropdownOpen, setDropdownOpen] = useState(false); const [copyOpen, setCopyOpen] = useState(false); const [copyToEnv, setCopyToEnv] = useState(AppEnv.Sandbox); - const [deleteOpen, setDeleteOpen] = useState(false); const { counts } = useProductsQuery(); const productCounts = counts[product.id]; @@ -45,12 +49,6 @@ export const ProductListRowToolbar = ({ product }: { product: ProductV2 }) => { product={product} targetEnv={copyToEnv} /> - @@ -95,7 +93,7 @@ export const ProductListRowToolbar = ({ product }: { product: ProductV2 }) => { e.stopPropagation(); e.preventDefault(); setDropdownOpen(false); - setDeleteOpen(true); + onDeleteClick?.(product); }} > diff --git a/vite/src/views/products/products/components/product-list/ProductListTable.tsx b/vite/src/views/products/products/components/product-list/ProductListTable.tsx index ede408760..8688ac5fb 100644 --- a/vite/src/views/products/products/components/product-list/ProductListTable.tsx +++ b/vite/src/views/products/products/components/product-list/ProductListTable.tsx @@ -1,7 +1,6 @@ import { isOneOffProductV2, type ProductV2 } from "@autumn/shared"; -import { CubeIcon } from "@phosphor-icons/react"; import type { SortingState } from "@tanstack/react-table"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Table } from "@/components/general/table"; import { SectionTag } from "@/components/v2/badges/SectionTag"; import { EmptyState } from "@/components/v2/empty-states/EmptyState"; @@ -9,9 +8,9 @@ import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { pushPage } from "@/utils/genUtils"; import { useProductsQueryState } from "@/views/products/hooks/useProductsQueryState"; import { useProductTable } from "@/views/products/hooks/useProductTable"; +import { DeletePlanDialog } from "@/views/products/plan/components/DeletePlanDialog"; import { createProductListColumns } from "./ProductListColumns"; import { ProductListCreateButton } from "./ProductListCreateButton"; -import { ProductListMenuButton } from "./ProductListMenuButton"; type ProductWithCounts = ProductV2 & { active_count?: number; @@ -24,6 +23,16 @@ export function ProductListTable() { // Shared sorting state for all tables const [sorting, setSorting] = useState([]); + // Delete dialog state - lifted here so dialog doesn't unmount when row is removed + const [deleteDialog, setDeleteDialog] = useState<{ + open: boolean; + product: ProductV2 | null; + }>({ open: false, product: null }); + + const handleDeleteClick = useCallback((product: ProductV2) => { + setDeleteDialog({ open: true, product }); + }, []); + const { recurringBasePlans, recurringAddOnPlans, oneTimePlans } = useMemo(() => { const filtered = products?.filter((product) => @@ -93,8 +102,12 @@ export function ProductListTable() { ); const columns = useMemo( - () => createProductListColumns({ showGroup: hasAnyGroup }), - [hasAnyGroup], + () => + createProductListColumns({ + showGroup: hasAnyGroup, + onDeleteClick: handleDeleteClick, + }), + [hasAnyGroup, handleDeleteClick], ); const recurringBaseTable = useProductTable({ @@ -154,7 +167,7 @@ export function ProductListTable() {
{showTableStructure ? ( <> - {/* Recurring Plans Section */} + {/* Plans Section */}
- -
- - - Recurring Plans - - -
-
- - -
-
-
-
-
- {hasRecurringAddOns && Main Plans} + Subscriptions @@ -194,7 +191,7 @@ export function ProductListTable() {
- {/* Recurring Add-ons (only shown if add-ons exist) */} + {/* Add-on Plans (only shown when add-ons exist) */} {hasRecurringAddOns && ( - Add-ons + Add-on subscriptions )} -
- {/* One-Time Plans Section (only shown if one-time plans exist) */} - {hasOneTimePlans && ( -
- - - - - One-off Plans - - - - - - - - -
- )} + {/* One-time Plans (always shown) */} + + + One-off purchases + + + + + +
) : ( } /> )} + + {/* Delete dialog rendered at table level to prevent unmounting when row is removed */} + {deleteDialog.product && ( + setDeleteDialog((prev) => ({ ...prev, open }))} + /> + )}
); } diff --git a/vite/src/views/products/products/hooks/usePricingConfigSave.ts b/vite/src/views/products/products/hooks/usePricingConfigSave.ts new file mode 100644 index 000000000..d5dfd50e2 --- /dev/null +++ b/vite/src/views/products/products/hooks/usePricingConfigSave.ts @@ -0,0 +1,147 @@ +import type { AgentPricingConfig, UpdateProductV2Params } from "@autumn/shared"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { useOrg } from "@/hooks/common/useOrg"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr, pushPage } from "@/utils/genUtils"; +import { + getChangedProductIds, + getVersionedProductIds, +} from "@/views/onboarding4/preview/previewTypes"; +import { updateProduct } from "@/views/products/product/utils/updateProduct"; + +export const usePricingConfigSave = ({ + initialConfig, +}: { + initialConfig: AgentPricingConfig | null; +}) => { + const axiosInstance = useAxiosInstance(); + const navigate = useNavigate(); + const { org, mutate: mutateOrg } = useOrg(); + const { + counts, + isCountsLoading, + refetch: refetchProducts, + } = useProductsQuery(); + + const [isSaving, setIsSaving] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [versionedProductIds, setVersionedProductIds] = useState([]); + const [pendingConfig, setPendingConfig] = useState( + null, + ); + + const executeSave = async ({ config }: { config: AgentPricingConfig }) => { + // Push new products/features (existing are skipped server-side) + await axiosInstance.post("/v1/configs/push", { + features: config.features, + products: config.products, + }); + + const changedIds = getChangedProductIds({ + initialConfig, + currentConfig: config, + }); + const existingIds = new Set(initialConfig?.products.map((p) => p.id) ?? []); + + for (const productId of changedIds) { + if (!existingIds.has(productId)) continue; + const agentProduct = config.products.find((p) => p.id === productId); + if (!agentProduct) continue; + + await updateProduct({ + axiosInstance, + productId, + product: { + id: agentProduct.id, + name: agentProduct.name, + is_add_on: agentProduct.is_add_on, + is_default: agentProduct.is_default, + group: agentProduct.group || null, // empty string → null + items: agentProduct.items ?? [], + free_trial: agentProduct.free_trial ?? null, + } satisfies UpdateProductV2Params, + onSuccess: async () => {}, + }); + } + }; + + const handleSave = async ({ + config, + }: { + config: AgentPricingConfig | null; + }) => { + if (!config) return; + if (isCountsLoading) { + toast.error("Plan counts are loading"); + return; + } + + setIsSaving(true); + try { + const versionedIds = getVersionedProductIds({ + initialConfig, + currentConfig: config, + productCounts: counts, + }); + + if (versionedIds.length > 0) { + setVersionedProductIds(versionedIds); + setPendingConfig(config); + setConfirmOpen(true); + return; + } + + await executeSave({ config }); + toast.success("Changes saved successfully"); + await refetchProducts(); + + if (!org?.onboarded) { + await axiosInstance.patch("/v1/organization", { onboarded: true }); + await mutateOrg(); + } + + pushPage({ path: "/products", navigate }); + } catch (error) { + console.error("Error saving changes:", error); + toast.error(getBackendErr(error, "Failed to save changes")); + } finally { + setIsSaving(false); + } + }; + + const handleConfirmSave = async () => { + if (!pendingConfig) return; + setIsSaving(true); + try { + await executeSave({ config: pendingConfig }); + toast.success("Changes saved successfully"); + await refetchProducts(); + + if (!org?.onboarded) { + await axiosInstance.patch("/v1/organization", { onboarded: true }); + await mutateOrg(); + } + + pushPage({ path: "/products", navigate }); + setConfirmOpen(false); + setPendingConfig(null); + } catch (error) { + console.error("Error saving changes:", error); + toast.error(getBackendErr(error, "Failed to save changes")); + } finally { + setIsSaving(false); + } + }; + + return { + isSaving, + confirmOpen, + setConfirmOpen, + versionedProductIds, + handleSave, + handleConfirmSave, + }; +};