diff --git a/.github/workflows/vite-build.yml b/.github/workflows/vite-build.yml new file mode 100644 index 000000000..fc194d98d --- /dev/null +++ b/.github/workflows/vite-build.yml @@ -0,0 +1,43 @@ +name: Vite Build 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 vite && bunx tsc --noEmit + + build: + name: Build + 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: Build Vite + run: cd vite && bunx vite build diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 000000000..a2f39f6c8 --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "linear": { + "type": "remote", + "url": "https://mcp.linear.app/mcp", + "oauth": {} + } + } +} diff --git a/server/experiments/test.ts b/server/experiments/test.ts index 8141e7944..9431a2c4a 100644 --- a/server/experiments/test.ts +++ b/server/experiments/test.ts @@ -8,8 +8,9 @@ export const test = async () => { env: AppEnv.Sandbox, }); - const subscription = await stripeCli.subscriptions.retrieve("sub_1Sr2ln5NEqgjQ4gyJfi9oZVl"); - console.log(subscription); + // 1. Create stripe empty price? + + }; await test(); diff --git a/server/src/_luaScriptsV2/deleteFullCustomerCache/setFullCustomerCache.lua b/server/src/_luaScriptsV2/deleteFullCustomerCache/setFullCustomerCache.lua new file mode 100644 index 000000000..b84d8b2aa --- /dev/null +++ b/server/src/_luaScriptsV2/deleteFullCustomerCache/setFullCustomerCache.lua @@ -0,0 +1,55 @@ +--[[ + Set FullCustomer in Redis cache. + + Atomically: + 1. Checks if stale-write guard exists and is newer than fetchTime (skip if so) + 2. Checks if cache already exists (skip if so, unless overwrite is true) + 3. Sets the cache using JSON.SET + 4. Sets TTL on the cache key + + KEYS: + [1] guardKey - stale-write guard key to check + [2] cacheKey - cache key to set + + ARGV: + [1] fetchTimeMs - timestamp when data was fetched from Postgres + [2] cacheTtl - TTL in seconds for the cache key + [3] serializedData - JSON string of the FullCustomer data + [4] overwrite - "1" to overwrite existing cache, "0" to skip if exists + + Returns: + "STALE_WRITE" = guard exists with newer timestamp, write blocked + "CACHE_EXISTS" = cache already exists, write skipped (only when overwrite is false) + "OK" = cache set successfully +]] + +local guardKey = KEYS[1] +local cacheKey = KEYS[2] +local fetchTimeMs = tonumber(ARGV[1]) +local cacheTtl = tonumber(ARGV[2]) +local serializedData = ARGV[3] +local overwrite = ARGV[4] == "true" + +-- Check if guard exists (deletion happened recently) +-- Skip check if either value is nil/null/falsey +local guardTime = redis.call("GET", guardKey) +if guardTime and guardTime ~= cjson.null and fetchTimeMs then + local guardTimeNum = tonumber(guardTime) + if guardTimeNum and guardTimeNum > fetchTimeMs then + return "STALE_WRITE" + end +end + +-- Check if cache already exists (skip this check if overwrite is true) +if not overwrite then + local existing = redis.call("JSON.TYPE", cacheKey) + if existing then + return "CACHE_EXISTS" + end +end + +-- Set the cache using JSON.SET +redis.call("JSON.SET", cacheKey, "$", serializedData) +redis.call("EXPIRE", cacheKey, cacheTtl) + +return "OK" diff --git a/server/src/_luaScriptsV2/luaScriptsV2.ts b/server/src/_luaScriptsV2/luaScriptsV2.ts index a510eb27a..b04919b7c 100644 --- a/server/src/_luaScriptsV2/luaScriptsV2.ts +++ b/server/src/_luaScriptsV2/luaScriptsV2.ts @@ -76,6 +76,15 @@ export const DELETE_FULL_CUSTOMER_CACHE_SCRIPT = readFileSync( "utf-8", ); +/** + * Lua script for setting a FullCustomer cache in Redis. + * Checks stale-write guard, checks if cache exists, and sets cache atomically. + */ +export const SET_FULL_CUSTOMER_CACHE_SCRIPT = readFileSync( + join(DELETE_CACHE_DIR, "setFullCustomerCache.lua"), + "utf-8", +); + /** * Lua script for batch deleting multiple FullCustomer caches from Redis. * For each customer: checks test guard, sets stale-write guard, deletes cache. diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index ba14b798a..0b4b0ac22 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -17,6 +17,7 @@ import { BATCH_DELETE_FULL_CUSTOMER_CACHE_SCRIPT, DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT, DELETE_FULL_CUSTOMER_CACHE_SCRIPT, + SET_FULL_CUSTOMER_CACHE_SCRIPT, } from "../../_luaScriptsV2/luaScriptsV2.js"; // if (!process.env.CACHE_URL) { @@ -173,6 +174,11 @@ const configureRedisInstance = (redisInstance: Redis): Redis => { lua: BATCH_DELETE_FULL_CUSTOMER_CACHE_SCRIPT, }); + redisInstance.defineCommand("setFullCustomerCache", { + numberOfKeys: 2, + lua: SET_FULL_CUSTOMER_CACHE_SCRIPT, + }); + redisInstance.on("error", (error) => { console.error(`[Redis] Connection error:`, error.message); }); @@ -338,6 +344,14 @@ declare module "ioredis" { guardTtl: string, customersJson: string, ): Promise; + setFullCustomerCache( + guardKey: string, + cacheKey: string, + fetchTimeMs: string, + cacheTtl: string, + serializedData: string, + overwrite: string, + ): Promise<"STALE_WRITE" | "CACHE_EXISTS" | "OK">; } } diff --git a/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSchedulePhaseCompleted.ts b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSchedulePhaseCompleted.ts index c7b1c657f..75adba428 100644 --- a/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSchedulePhaseCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSchedulePhaseCompleted.ts @@ -3,7 +3,7 @@ import { CusProductStatus, type FullCustomer, formatMs, - isCustomerProductExpired, + hasCustomerProductEnded, } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { stripeCustomerToNowMs } from "@/external/stripe/customers/index"; @@ -66,7 +66,7 @@ export const handleSchedulePhaseCompleted = async ({ ); for (const cusProduct of customerProducts) { - const shouldExpire = isCustomerProductExpired(cusProduct, { nowMs }); + const shouldExpire = hasCustomerProductEnded(cusProduct, { nowMs }); if (shouldExpire) { logger.info( diff --git a/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts b/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts index 0a94ad877..9505c9400 100644 --- a/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts +++ b/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts @@ -1,7 +1,7 @@ import type { ApiBalance, Feature, FullCustomer } from "@autumn/shared"; import { - fullCustomerToCustomerEntitlements, findCustomerEntitlementById, + fullCustomerToCustomerEntitlements, getRelevantFeatures, } from "@autumn/shared"; import { Decimal } from "decimal.js"; diff --git a/server/src/internal/balances/utils/handleThresholdReached.ts b/server/src/internal/balances/utils/handleThresholdReached.ts index aaf85102d..4726232e8 100644 --- a/server/src/internal/balances/utils/handleThresholdReached.ts +++ b/server/src/internal/balances/utils/handleThresholdReached.ts @@ -47,7 +47,8 @@ export const handleAllowanceUsed = async ({ oldFullCus: FullCustomer; newFullCus: FullCustomer; }) => { - for (const cusProduct of newFullCus.customer_products) { + const clonedNewFullCus = structuredClone(newFullCus); + for (const cusProduct of clonedNewFullCus.customer_products) { for (const cusEnt of cusProduct.customer_entitlements) { cusEnt.usage_allowed = false; } @@ -61,7 +62,7 @@ export const handleAllowanceUsed = async ({ const { apiCustomer: newApiCustomer, legacyData: newLegacyData } = await getApiCustomerBase({ ctx, - fullCus: newFullCus, + fullCus: clonedNewFullCus, }); const prevCusFeature = prevApiCustomer.balances[feature.id]; diff --git a/server/src/internal/billing/v2/updateSubscription/errors/handleCurrentCustomerProductErrors.ts b/server/src/internal/billing/v2/updateSubscription/errors/handleCurrentCustomerProductErrors.ts new file mode 100644 index 000000000..6f47ebfd0 --- /dev/null +++ b/server/src/internal/billing/v2/updateSubscription/errors/handleCurrentCustomerProductErrors.ts @@ -0,0 +1,26 @@ +import { + isCustomerProductExpired, + isCustomerProductScheduled, + RecaseError, +} from "@autumn/shared"; +import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; + +export const handleCurrentCustomerProductErrors = ({ + billingContext, +}: { + billingContext: UpdateSubscriptionBillingContext; +}) => { + const { customerProduct } = billingContext; + + if (isCustomerProductScheduled(customerProduct)) { + throw new RecaseError({ + message: `Cannot update subscription for '${customerProduct.product.name}' because it is scheduled and not yet active`, + }); + } + + if (isCustomerProductExpired(customerProduct)) { + throw new RecaseError({ + message: `Cannot update subscription for '${customerProduct.product.name}' because it has expired`, + }); + } +}; diff --git a/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts b/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts index aaa60ed18..10bbd3915 100644 --- a/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts +++ b/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts @@ -8,6 +8,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan"; import { handleCancelEndOfCycleErrors } from "@/internal/billing/v2/updateSubscription/errors/handleCancelEndOfCycleErrors"; +import { handleCurrentCustomerProductErrors } from "./handleCurrentCustomerProductErrors"; import { handleCustomPlanErrors } from "./handleCustomPlanErrors"; import { handleFeatureQuantityErrors } from "./handleFeatureQuantityErrors"; import { @@ -37,6 +38,9 @@ export const handleUpdateSubscriptionErrors = async ({ }); } + // 1. Current customer product errors + handleCurrentCustomerProductErrors({ billingContext }); + // 2. Product type transition errors handleProductTypeTransitionErrors({ billingContext, autumnBillingPlan }); diff --git a/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts b/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts index 8b6d5caf6..5c618f4ed 100644 --- a/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts +++ b/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts @@ -67,10 +67,6 @@ export const handlePreviewUpdateSubscription = createRoute({ }, }); - return c.json({ - ...previewResponse, - autumn: autumnBillingPlan, - stripe: stripeBillingPlan, - }); + return c.json(previewResponse); }, }); diff --git a/server/src/internal/customers/CusSearchService.ts b/server/src/internal/customers/CusSearchService.ts index ae97f6228..972531e57 100644 --- a/server/src/internal/customers/CusSearchService.ts +++ b/server/src/internal/customers/CusSearchService.ts @@ -10,12 +10,14 @@ import { and, desc, eq, - gt, ilike, isNotNull, + gt, + ilike, + isNotNull, isNull, lt, notExists, or, - sql + sql, } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { DrizzleCli } from "@/db/initDrizzle.js"; @@ -155,6 +157,16 @@ export class CusSearchService { ? or( ...statuses.map((status) => { switch (status) { + case "active": + return and( + eq(customerProducts.status, CusProductStatus.Active), + isNull(customerProducts.canceled_at), + ); + case "past_due": + return and( + eq(customerProducts.status, CusProductStatus.PastDue), + isNull(customerProducts.canceled_at), + ); case "canceled": return and( isNotNull(customerProducts.canceled_at), @@ -164,6 +176,7 @@ export class CusSearchService { return and( gt(customerProducts.trial_ends_at, Date.now()), isNotNull(customerProducts.free_trial_id), + isNull(customerProducts.canceled_at), activeProdFilter, ); case CusProductStatus.Expired: @@ -513,7 +526,8 @@ export class CusSearchService { }); } - if (filters?.version && filters?.version.length > 0) { + // Call searchByProduct if we have version filters OR status filters + if ((filters?.version && filters?.version.length > 0) || (filters?.status && filters?.status.length > 0)) { return await CusSearchService.searchByProduct({ db, orgId, @@ -642,7 +656,6 @@ export class CusSearchService { return { data: finalResults, count: totalCount }; } } - // // Legacy support for product_id field (if still used) // let productIds: string[] = []; // if (filters.product_id) { diff --git a/server/src/internal/customers/cusRouter.ts b/server/src/internal/customers/cusRouter.ts index 3876bb7b1..995760e19 100644 --- a/server/src/internal/customers/cusRouter.ts +++ b/server/src/internal/customers/cusRouter.ts @@ -15,7 +15,7 @@ import { handleUpdateBalancesV2 } from "./handlers/handleUpdateBalancesV2.js"; import { handleUpdateCustomerV2 } from "./handlers/handleUpdateCustomerV2.js"; export const expressCusRouter = express.Router(); -expressCusRouter.get("/:customer_id/billing_portal", handleGetBillingPortal); +// expressCusRouter.get("/:customer_id/billing_portal", handleGetBillingPortal); export const cusRouter = new Hono(); @@ -35,6 +35,7 @@ cusRouter.post("/:customer_id/transfer", ...handleTransferProductV2); // Billing portal cusRouter.post("/:customer_id/billing_portal", ...handleCreateBillingPortal); +cusRouter.get("/:customer_id/billing_portal", ...handleGetBillingPortal); // Legacy... cusRouter.post("/:customer_id/balances", ...handleUpdateBalancesV2); diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts index 6b92c51c3..5a8a71abe 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts @@ -177,12 +177,14 @@ export const getOrCreateCachedFullCustomer = async ({ // 6. Set cache (await to ensure it's ready before Redis deduction) if (!skipCache && setCache) { + // Note (to fix): causes race condition when cache isn't set and concurrent track requests each set the cache. await setCachedFullCustomer({ ctx, fullCustomer, customerId: fullCustomer.id || fullCustomer.internal_id, fetchTimeMs, source, + overwrite: true, }).catch((err) => logger.error(`Failed to set cache: ${err}`)); } diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.ts index 746835443..d54cfc483 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.ts @@ -2,6 +2,7 @@ import type { FullCustomer } from "@autumn/shared"; import { redis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { addToExtraLogs } from "@/utils/logging/addToExtraLogs.js"; import { buildFullCustomerCacheGuardKey, buildFullCustomerCacheKey, @@ -20,12 +21,14 @@ export const setCachedFullCustomer = async ({ customerId, fetchTimeMs, source, + overwrite = false, }: { ctx: AutumnContext; fullCustomer: FullCustomer; customerId: string; fetchTimeMs: number; source?: string; + overwrite?: boolean; }): Promise => { const { org, env, logger } = ctx; @@ -41,24 +44,14 @@ export const setCachedFullCustomer = async ({ }); const result = await tryRedisWrite(async () => { - // Check if guard exists (deletion happened recently) - const guardTime = await redis.get(guardKey); - if (guardTime && Number(guardTime) > fetchTimeMs) { - return "STALE_WRITE" as const; - } - - // Check if cache already exists (JSON.TYPE returns null if key doesn't exist) - const existing = await redis.call("JSON.TYPE", cacheKey); - if (existing) { - return "CACHE_EXISTS" as const; - } - - // Set the cache using JSON.SET (stores as native JSON for JSONPath operations) - const serialized = JSON.stringify(fullCustomer); - await redis.call("JSON.SET", cacheKey, "$", serialized); - await redis.expire(cacheKey, FULL_CUSTOMER_CACHE_TTL_SECONDS); - - return "OK" as const; + return await redis.setFullCustomerCache( + guardKey, + cacheKey, + String(fetchTimeMs), + String(FULL_CUSTOMER_CACHE_TTL_SECONDS), + JSON.stringify(fullCustomer), + String(overwrite), + ); }); if (result === null) { @@ -66,19 +59,18 @@ export const setCachedFullCustomer = async ({ return "FAILED"; } - if (result === "STALE_WRITE") { - logger.info( - `[setCachedFullCustomer] Stale write blocked for ${customerId}, source: ${source}`, - ); - } else if (result === "CACHE_EXISTS") { - logger.debug( - `[setCachedFullCustomer] Cache already exists for ${customerId}, source: ${source}`, - ); - } else { - logger.info( - `[setCachedFullCustomer] Set cache for ${customerId}, source: ${source}`, - ); - } + logger.info( + `[setCachedFullCustomer] ${customerId}: ${result}, source: ${source}`, + ); + addToExtraLogs({ + ctx, + extras: { + setCache: { + result, + fullCustomer, + }, + }, + }); return result; }; diff --git a/server/src/internal/customers/handlers/handleBillingPortal/handleGetBillingPortal.ts b/server/src/internal/customers/handlers/handleBillingPortal/handleGetBillingPortal.ts index a08b16429..b8bd5faa0 100644 --- a/server/src/internal/customers/handlers/handleBillingPortal/handleGetBillingPortal.ts +++ b/server/src/internal/customers/handlers/handleBillingPortal/handleGetBillingPortal.ts @@ -1,67 +1,63 @@ -import { ErrCode, RecaseError } from "@autumn/shared"; +import { + ErrCode, + GetBillingPortalQuerySchema, + RecaseError, +} from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; +import z from "zod/v4"; +import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; import { createStripeCli } from "../../../../external/connect/createStripeCli"; -import { createStripeCusIfNotExists } from "../../../../external/stripe/stripeCusUtils"; -import { routeHandler } from "../../../../utils/routerUtils"; -import { OrgService } from "../../../orgs/OrgService"; import { toSuccessUrl } from "../../../orgs/orgUtils/convertOrgUtils"; import { CusService } from "../../CusService"; -export const handleGetBillingPortal = (req: any, res: any) => - routeHandler({ - req, - res, - action: "get billing portal", - handler: async (req, res) => { - const returnUrl = req.query.return_url; - const customerId = req.params.customer_id; - const [org, customer] = await Promise.all([ - OrgService.getFromReq(req), - CusService.get({ - db: req.db, - idOrInternalId: customerId, - orgId: req.orgId, - env: req.env, - }), - ]); +export const handleGetBillingPortal = createRoute({ + query: GetBillingPortalQuerySchema, + params: z.object({ + customer_id: z.string(), + }), + // body: GetBillingPortalBodySchema, + handler: async (c) => { + const returnUrl = c.req.valid("query").return_url; + const customerId = c.req.param().customer_id; + const ctx = c.get("ctx"); + const [customer] = await Promise.all([ + CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }), + ]); - if (!customer) { - throw new RecaseError({ - message: `Customer ${customerId} not found`, - code: ErrCode.CustomerNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - const stripeCli = createStripeCli({ org, env: req.env }); - - let stripeCusId: string = customer.processor?.id; - if (!customer.processor?.id) { - const newCus = await createStripeCusIfNotExists({ - db: req.db, - org, - env: req.env, - customer, - logger: req.logger, - }); - - if (!newCus) { - throw new RecaseError({ - message: `Failed to create Stripe customer`, - }); - } - - stripeCusId = newCus.id; - } - - const portal = await stripeCli.billingPortal.sessions.create({ - customer: stripeCusId, - return_url: returnUrl || toSuccessUrl({ org, env: req.env }), + if (!customer) { + throw new RecaseError({ + message: `Customer ${customerId} not found`, + code: ErrCode.CustomerNotFound, + statusCode: StatusCodes.NOT_FOUND, }); + } - res.status(200).json({ - customer_id: customer.id || null, - url: portal.url, - }); - }, - }); + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + + const stripeCustomer = await createStripeCusIfNotExists({ + db: ctx.db, + org: ctx.org, + env: ctx.env, + customer, + logger: ctx.logger, + }); + + const stripeCusId = stripeCustomer.id; + + const portal = await stripeCli.billingPortal.sessions.create({ + customer: stripeCusId, + return_url: returnUrl || toSuccessUrl({ org: ctx.org, env: ctx.env }), + }); + + return c.json({ + customer_id: customer.id || null, + url: portal.url, + }); + }, +}); diff --git a/server/src/internal/events/EventsAggregationService.ts b/server/src/internal/events/EventsAggregationService.ts index 1127b9cf0..1513e0e16 100644 --- a/server/src/internal/events/EventsAggregationService.ts +++ b/server/src/internal/events/EventsAggregationService.ts @@ -305,9 +305,9 @@ export class EventsAggregationService { }>; const distinctCount = Number(distinctJson.data[0]?.distinct_count ?? 0); - if (distinctCount > 30) { + if (distinctCount > 100) { throw new RecaseError({ - message: `Too many distinct group values (${distinctCount}). Maximum allowed is 30. Please choose a property with fewer unique values.`, + message: `Too many distinct group values (${distinctCount}). Maximum allowed is 100. Please choose a property with fewer unique values.`, code: ErrCode.InvalidInputs, statusCode: StatusCodes.BAD_REQUEST, }); diff --git a/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts b/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts index ff8e341d8..410fe1063 100644 --- a/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts +++ b/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts @@ -53,7 +53,7 @@ const FeatureSchema = z .describe( "Unique ID for the feature (lowercase, underscores, no spaces)", ), - name: z.string().nullish().describe("Display name for the feature"), + 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", ), @@ -96,6 +96,14 @@ const FeatureSchema = z }, ); +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() @@ -117,6 +125,12 @@ const ProductItemSchema = z.object({ .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.", ), @@ -232,9 +246,10 @@ export type PricingConfig = z.infer; // ============ SYSTEM PROMPT ============ const SYSTEM_PROMPT = `You are a helpful pricing configuration assistant for Autumn, a billing and entitlements platform. -Your job is to help users design their pricing model through natural conversation. You should: +Your job is to help users set up their pricing model through natural conversation. You should: 1. Ask clarifying questions to understand their needs 2. Generate and update the pricing configuration as you learn more +3. Read through these instructions carefully for every single message, and follow them exactly. **IMPORTANT**: Call the build_pricing tool EVERY time the user provides any information at all about their pricing, features, or products. This updates the live preview they see. Even partial information should trigger a tool call with your best interpretation. @@ -245,6 +260,8 @@ Your job is to help users design their pricing model through natural conversatio - **boolean**: On/off features (advanced analytics, priority support, SSO) - **credit_system**: A unified credit pool that maps to multiple single_use features + + ## Item Types Products contain an array of items. There are THREE distinct item patterns: @@ -264,7 +281,11 @@ Products contain an array of items. There are THREE distinct item patterns: \`{ feature_id: "credits", price: 10, usage_model: "prepaid", billing_units: 10000 }\` → Customer pays $10 once to receive 10,000 credits -5. **Per-Unit Pricing Structure**: +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" }\` + → Customer gets 1,000 API calls free, then pays $0.02/call up to 5,000, then $0.01/call after that. + +6. **Per-Unit Pricing Structure**: For any "per-X" pricing (like "$Y per seat", "$Y per project", "$Y per website"), ALWAYS use this pattern: - Base subscription fee: \`{ feature_id: null, price: 10, interval: "month" }\` - Unit allocation: \`{ feature_id: "seats", included_usage: 1, price: 10, usage_model: "pay_per_use", billing_units: 1 }\` @@ -273,10 +294,13 @@ This creates: $Y/month base price that includes 1 unit, then $Y per additional u + ## Guidelines when building the config - Refer to the Item Types section above to see examples of how to build the config. +- **Features vs Items**: Features define WHAT can be tracked (e.g., "credits"). Items define HOW that feature is granted in a product (recurring, one-time, free, paid). NEVER create duplicate features for the same underlying resource. For example, "monthly tokens" and "one-time tokens" should be the SAME feature ("tokens"), referenced by different items and intervals. + - If you identify more than 3 features from user input, build the 3 most important (prioritizing metered features) and ask the user to confirm if they want to add more. Inform them clearly that you kept it simple to start with, but they can add more later. - Product and Feature IDs should be lowercase with underscores (e.g., "pro_plan", "chat_messages") @@ -288,15 +312,17 @@ This creates: $Y/month base price that includes 1 unit, then $Y per additional u - For annual variants of plans, create another separate plan but with the annual price interval. Name it - Annual + + ## Guidelines when responding to the user - Do NOT tell the user what pricing you have built or describe the pricing in any way, as it is a waste to read (they can see it on the right). - If the user asks about changing currency, let them know they can do so in the Autumn dashboard, under Developer > Stripe. -- If the user has a price for a feature, clarify whether it should be a usage-based (pay_per_use) or prepaid (prepaid) pricing +- If the user has a price for a feature, ask them whether it should be a usage-based (pay_per_use) or prepaid (prepaid) pricing -- If you don't know something, DO NOT make up information or assume anything. They can reach us on discord here: https://discord.gg/atmn (we're very responsive) +- If the user asks about pricing or functionality that you are not sure whether is possible, DO NOT make up information or assume it can be done. They can reach us on discord here: https://discord.gg/atmn (we're very responsive) - Keep responses very concise and friendly.`; @@ -319,7 +345,7 @@ pricingAgentRouter.post("/chat", async (c) => { const anthropicClient = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }); - const baseModel = anthropicClient("claude-sonnet-4-20250514"); + const baseModel = anthropicClient("claude-opus-4-5"); const posthog = getPostHogClient(); const distinctId = ctx.userId || ctx.org?.id || "anonymous"; diff --git a/server/src/internal/products/handlers/handleGetProductDeleteInfo.ts b/server/src/internal/products/handlers/handleGetProductDeleteInfo.ts index 06b24f9b9..432918051 100644 --- a/server/src/internal/products/handlers/handleGetProductDeleteInfo.ts +++ b/server/src/internal/products/handlers/handleGetProductDeleteInfo.ts @@ -1,68 +1,51 @@ -import { ProductNotFoundError } from "@autumn/shared"; +import { AffectedResource, ProductNotFoundError } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService.js"; -import { handleRequestError } from "@/utils/errorUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; -import { routeHandler } from "@/utils/routerUtils.js"; import { ProductService } from "../ProductService.js"; -export const handleGetProductDeleteInfo = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "Get product deletion info", - handler: async (req: ExtendedRequest, res: any) => { - try { - // 1. Get number of versions - const { db } = req; - const productId = Array.isArray(req.params.productId) - ? req.params.productId[0] - : req.params.productId; +export const handleGetProductDeleteInfo = createRoute({ + resource: AffectedResource.Product, + handler: async (c) => { + // 1. Get number of versions + const ctx = c.get("ctx"); + const { db, org, env } = ctx; + const { productId } = c.req.param(); - const product = await ProductService.get({ - db, - id: productId, - orgId: req.orgId, - env: req.env, - }); + const product = await ProductService.get({ + db, + id: productId, + orgId: org.id, + env, + }); - if (!product) { - throw new ProductNotFoundError({ productId }); - } + if (!product) { + throw new ProductNotFoundError({ productId }); + } - const [allVersions, latestVersion, deletionText] = await Promise.all([ - CusProdReadService.existsForProduct({ - db, - productId, - }), - CusProdReadService.existsForProduct({ - db, - internalProductId: product.internal_id, - }), - ProductService.getDeletionText({ - db, - productId, - orgId: req.orgId, - env: req.env, - }), - ]); + const [allVersions, latestVersion, deletionText] = await Promise.all([ + CusProdReadService.existsForProduct({ + db, + productId, + }), + CusProdReadService.existsForProduct({ + db, + internalProductId: product.internal_id, + }), + ProductService.getDeletionText({ + db, + productId, + orgId: org.id, + env, + }), + ]); - res.status(200).send({ - numVersion: product.version, - hasCusProducts: allVersions, - hasCusProductsLatest: latestVersion, - customerName: - deletionText[0]?.name || - deletionText[0]?.email || - deletionText[0]?.id, - totalCount: deletionText[0]?.totalCount, - }); - } catch (error) { - handleRequestError({ - error, - req, - res, - action: "Get product info", - }); - } - }, - }); + return c.json({ + numVersion: product.version, + hasCusProducts: allVersions, + hasCusProductsLatest: latestVersion, + customerName: + deletionText[0]?.name || deletionText[0]?.email || deletionText[0]?.id, + totalCount: deletionText[0]?.totalCount, + }); + }, +}); diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 45861ff2d..06795440f 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -1,37 +1,11 @@ import { Router } from "express"; -import { handleFrontendReqError } from "@/utils/errorUtils.js"; import { EntitlementService } from "./entitlements/EntitlementService.js"; import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js"; export const expressProductRouter: Router = Router({ mergeParams: true }); -expressProductRouter.get("/:productId/info", handleGetProductDeleteInfo); - -expressProductRouter.get( - "/has_entity_feature_id", - async (req: any, res: any) => { - try { - const { db, orgId, env } = req; - - const hasEntityFeatureId = await EntitlementService.hasEntityFeatureId({ - db, - orgId, - env, - }); - - res.status(200).send({ hasEntityFeatureId }); - } catch (error) { - handleFrontendReqError({ - error, - req, - res, - action: "Check has entity feature id", - }); - } - }, -); - import { Hono } from "hono"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleGetProducts } from "@/internal/products/internalHandlers/handleGetProducts.js"; import { handleCopyEnvironment } from "./handlers/handleCopyEnvironment/handleCopyEnvironment.js"; @@ -55,3 +29,21 @@ internalProductRouter.get("/migrations", ...handleGetMigrations); internalProductRouter.get("/:productId/count", ...handleGetProductCount); internalProductRouter.get("/:productId/data", ...handleGetProductInternal); internalProductRouter.post("/copy_to_production", ...handleCopyEnvironment); +internalProductRouter.get("/:productId/info", ...handleGetProductDeleteInfo); +internalProductRouter.get( + "/has_entity_feature_id", + ...createRoute({ + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org, env } = ctx; + + const hasEntityFeatureId = await EntitlementService.hasEntityFeatureId({ + db, + orgId: org.id, + env, + }); + + return c.json({ hasEntityFeatureId }); + }, + }), +); diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts index 46df6f435..22593307a 100644 --- a/server/src/internal/products/productRouter.ts +++ b/server/src/internal/products/productRouter.ts @@ -9,11 +9,10 @@ import { handleGetPlan } from "./handlers/handleGetPlan.js"; import { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js"; import { handleListPlans } from "./handlers/handleListPlans.js"; import { handleMigrateProductV2 } from "./handlers/handleMigrateProductV2.js"; -import { handlePlanHasCustomers } from "./handlers/handlePlanHasCustomers.js"; + import { handleUpdatePlan } from "./handlers/handleUpdateProduct/handleUpdatePlan.js"; export const expressProductRouter = express.Router(); -expressProductRouter.get("/:product_id/has_customers", handlePlanHasCustomers); export const honoProductBetaRouter = new Hono(); honoProductBetaRouter.get("", ...handleListPlans); @@ -37,6 +36,7 @@ honoProductRouter.delete("/:product_id", ...handleDeleteProductHono); honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2); // Info before deleting plan +honoProductRouter.get("/:product_id/has_customers", ...handlePlanHasCustomersV2); honoProductRouter.post( "/:product_id/has_customers", ...handlePlanHasCustomersV2, diff --git a/server/src/utils/logging/initLogger.ts b/server/src/utils/logging/initLogger.ts index fc78c9094..f3194ad77 100644 --- a/server/src/utils/logging/initLogger.ts +++ b/server/src/utils/logging/initLogger.ts @@ -134,11 +134,7 @@ export const initLogger = () => { stream: pino.transport({ target: "@axiomhq/pino", options: { - dataset: - process.env.NODE_ENV === "test" || - process.env.NODE_ENV === "development" - ? "server-dev" - : "express", + dataset: "express", token: process.env.AXIOM_TOKEN, }, }), diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 1864dd16f..66e0c317f 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -1,10 +1,20 @@ import { beforeAll, describe } from "bun:test"; -import { ApiVersion } from "@autumn/shared"; +import { + ApiVersion, + BillingInterval, + type FullProduct, + isConsumablePrice, + isFixedPrice, +} from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; +import type { Stripe } from "stripe"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { ProductService } from "@/internal/products/ProductService"; +import { constructPriceItem } from "@/internal/products/product-items/productItemUtils"; import { + constructArrearItem, constructFeatureItem, constructPrepaidItem, } from "@/utils/scriptUtils/constructItem.js"; @@ -12,9 +22,6 @@ import { constructProduct, constructRawProduct, } from "@/utils/scriptUtils/createTestProducts.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3"; -import { expectSubToBeCorrect } from "../merged/mergeUtils/expectSubCorrect"; const prepaidUsersItem = constructPrepaidItem({ featureId: TestFeature.Users, @@ -32,68 +39,107 @@ const free = constructProduct({ ], }); -const oneOffCredits = constructRawProduct({ - id: "one_off_credits", +const growthYearly = constructRawProduct({ + id: "growth-yearly", items: [ - constructPrepaidItem({ - featureId: TestFeature.Credits, + constructArrearItem({ + featureId: TestFeature.Words, includedUsage: 0, - billingUnits: 1, - price: 0.01, - isOneOff: true, + price: 1, + billingUnits: 100, + }), + constructPriceItem({ + price: 2000, + interval: BillingInterval.Year, }), ], - // trial: true, }); const testCase = "temp"; +const buildSubscriptionItems = ({ + fullProduct, +}: { + fullProduct: FullProduct; +}): Stripe.SubscriptionScheduleCreateParams.Phase.Item[] => { + return fullProduct.prices.map((p) => { + if (isConsumablePrice(p)) { + return { + price: p.config.stripe_empty_price_id ?? undefined, + quantity: 0, + }; + } + return { + price: p.config.stripe_price_id ?? undefined, + quantity: 1, + }; + }); +}; + describe(`${chalk.yellowBright("temp: add on")}`, () => { const customerId = testCase; const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - attachPm: "success", + // await initCustomerV3({ + // ctx, + // customerId, + // withTestClock: true, + // attachPm: "success", + // }); + + // await initProductsV0({ + // ctx, + // products: [free, growthYearly], + // prefix: testCase, + // }); + + const { stripeCli } = ctx; + + const growthYearly = await ProductService.getFull({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + idOrInternalId: "growth-yearly_temp", }); - await initProductsV0({ - ctx, - products: [free], - prefix: testCase, - }); + // const basePrice = growthYearly.prices.find(isFixedPrice) - await autumnV1.attach({ - customer_id: customerId, - product_id: free.id, - options: [ + // const emptyPrice = await stripeCli.prices.create({ + // product: growthYearly?.processor?.id, + // unit_amount: 0, + // currency: "usd", + // recurring: { + // ...(billingIntervalToStripe({ + // interval: BillingInterval.Year, + // intervalCount: 1, + // }) as any), + // }, + // }); + + // console.log(emptyPrice); + + const newSubscription = await stripeCli.subscriptions.create({ + customer: "cus_ToYUVA6XSJrMa8", + items: [ { - feature_id: TestFeature.Users, - quantity: 10, + price: "price_1SqvPM5NEqgjQ4gyNktukeYr", + quantity: 1, }, ], + billing_mode: { type: "flexible" }, + billing_cycle_anchor: Math.floor(new Date("2026-12-26").getTime() / 1000), }); - const dashboardItem = constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }); - - await autumnV1.attach({ - customer_id: customerId, - product_id: free.id, - is_custom: true, - items: [prepaidUsersItem, dashboardItem], - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, + await stripeCli.subscriptions.update(newSubscription.id, { + items: [ + { + id: newSubscription.items.data[0].id, + deleted: true, + }, + ...buildSubscriptionItems({ fullProduct: growthYearly }) + ], + proration_behavior: "none", }); }); }); diff --git a/server/tests/attach/downgrade/downgrade3.test.ts b/server/tests/attach/downgrade/downgrade3.test.ts index 933dd9c9b..18c6fa97b 100644 --- a/server/tests/attach/downgrade/downgrade3.test.ts +++ b/server/tests/attach/downgrade/downgrade3.test.ts @@ -5,13 +5,13 @@ import { LegacyVersion, type Organization, } from "@autumn/shared"; -import chalk from "chalk"; -import type Stripe from "stripe"; import { TestFeature } from "@tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; import { expectDowngradeCorrect } from "@tests/utils/expectUtils/expectScheduleUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/internal/products/product-items/productItemUtils.js"; diff --git a/server/tests/balances/check/basic/check4.test.ts b/server/tests/balances/check/basic/check4.test.ts index ad0f903f9..1c8a3a2c2 100644 --- a/server/tests/balances/check/basic/check4.test.ts +++ b/server/tests/balances/check/basic/check4.test.ts @@ -80,7 +80,7 @@ describe(`${chalk.yellowBright("check4: test /check on unlimited feature")}`, () granted_balance: 0, max_purchase: null, overage_allowed: false, - plan_id: "check4_free", + plan_id: freeProd.id, purchased_balance: 0, reset: null, usage: 0, diff --git a/server/tests/balances/check/send-event/send-event1.test.ts b/server/tests/balances/check/send-event/send-event1.test.ts index ff42b8804..07232f5ce 100644 --- a/server/tests/balances/check/send-event/send-event1.test.ts +++ b/server/tests/balances/check/send-event/send-event1.test.ts @@ -50,6 +50,8 @@ describe(`${chalk.yellowBright("send-event1: Testing send event")}`, () => { customer_id: customerId, product_id: pro.id, }); + + await autumn.customers.get(customerId); // set cache }); test("should check with track for allocated feature", async () => { diff --git a/server/tests/balances/track/allocated/track-allocated5.test.ts b/server/tests/balances/track/allocated/track-allocated5.test.ts index d0ff5590d..a1bfa5a02 100644 --- a/server/tests/balances/track/allocated/track-allocated5.test.ts +++ b/server/tests/balances/track/allocated/track-allocated5.test.ts @@ -52,6 +52,8 @@ describe(`${chalk.yellowBright(`${testCase}: Tracking allocated feature with con product_id: free.id, }); + await autumn.customers.get(customerId); // set cache + const promises = []; let totalUsage = 0; let numberOfTracks = 0; diff --git a/server/tests/balances/track/breakdown/track-breakdown6.test.ts b/server/tests/balances/track/breakdown/track-breakdown6.test.ts index 08be49f72..73dab2adf 100644 --- a/server/tests/balances/track/breakdown/track-breakdown6.test.ts +++ b/server/tests/balances/track/breakdown/track-breakdown6.test.ts @@ -115,6 +115,8 @@ describe(`${chalk.yellowBright("track-breakdown6: exhaust into overage, then add value: deductValue, }); + console.log("Track result:", trackRes); + // 500 from prepaid, 100 from overage expect(trackRes.balance).toMatchObject({ granted_balance: 500, @@ -136,6 +138,7 @@ describe(`${chalk.yellowBright("track-breakdown6: exhaust into overage, then add await timeout(2000); }); + return; test("attach lifetime prepaid product", async () => { await autumnV2.attach({ diff --git a/server/tests/balances/track/legacy/track-legacy2.test.ts b/server/tests/balances/track/legacy/track-legacy2.test.ts index d5693f89f..71296d54f 100644 --- a/server/tests/balances/track/legacy/track-legacy2.test.ts +++ b/server/tests/balances/track/legacy/track-legacy2.test.ts @@ -1,7 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import type { LimitedItem } from "@autumn/shared"; +import { ApiVersion, type LimitedItem } from "@autumn/shared"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "../../../../src/utils/scriptUtils/constructItem.js"; import { constructProduct } from "../../../../src/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "../../../../src/utils/scriptUtils/testUtils/initCustomerV3.js"; @@ -26,6 +27,7 @@ const proWithOverage = constructProduct({ const testCase = "track-legacy2"; describe(`${chalk.yellowBright("track-legacy2: Testing /entitled & /events, for pro with overage")}`, () => { const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); beforeAll(async () => { await initProductsV0({ @@ -50,6 +52,8 @@ describe(`${chalk.yellowBright("track-legacy2: Testing /entitled & /events, for customerId: customerId, productId: proWithOverage.id, }); + + await autumn.customers.get(customerId); // set cache }); test("should have correct entitlements (pro with overage)", async () => { diff --git a/server/tests/balances/track/legacy/track-legacy3.test.ts b/server/tests/balances/track/legacy/track-legacy3.test.ts index f6c398e2e..7b47bdb76 100644 --- a/server/tests/balances/track/legacy/track-legacy3.test.ts +++ b/server/tests/balances/track/legacy/track-legacy3.test.ts @@ -1,7 +1,9 @@ import { beforeAll, describe, test } from "bun:test"; -import { ProductItemInterval } from "@autumn/shared"; +import { ApiVersion, ProductItemInterval } from "@autumn/shared"; +import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem, constructPrepaidItem, @@ -60,6 +62,7 @@ describe(`${chalk.yellowBright( let curAllowance = 0; const oneTimeBillingUnits = 100; // From oneTimeAddOnMetered1 prepaid item const oneTimeQuantity = 2 * oneTimeBillingUnits; + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); beforeAll(async () => { await initProductsV0({ @@ -78,19 +81,13 @@ describe(`${chalk.yellowBright( }); }); - // test("should have correct entitlements (free)", async function () { - // await checkEntitledOnProduct({ - // customerId: customerId, - // product: free, - // finish: true, - // }); - // }); - test("should attach pro", async () => { await AutumnCli.attach({ customerId: customerId, productId: pro.id, }); + + await autumn.customers.get(customerId); // set cache }); test("should have correct entitlements (pro)", async () => { @@ -122,6 +119,8 @@ describe(`${chalk.yellowBright( }, ], }); + + await autumn.customers.get(customerId); // set cache }); test("should have correct entitlements (one time top up)", async () => { diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-free-to-paid.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-free-to-paid.test.ts index 0b1460e9e..07a244c4c 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-free-to-paid.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-free-to-paid.test.ts @@ -398,3 +398,100 @@ test.concurrent(`${chalk.yellowBright("free-to-paid: update free users to alloca env: ctx.env, }); }); + +// 7. Converting free messages to unlimited with monthly price +test.concurrent(`${chalk.yellowBright("free-to-paid: convert to unlimited with monthly price")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "f2p-unlimited-price", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [free] }), + ], + actions: [s.attach({ productId: "base" })], + }); + + // Track some usage before update + const messagesUsage = 50; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Step 2: Add monthly price item (converting to paid) + const priceItem = items.monthlyPrice(); + + const updateParams1 = { + customer_id: customerId, + product_id: free.id, + items: [messagesItem, priceItem], + }; + + const preview1 = await autumnV1.subscriptions.previewUpdate(updateParams1); + + // Should charge $20 for monthly base price + expect(preview1.total).toEqual(20); + + await autumnV1.subscriptions.update(updateParams1); + + // Verify first update + const customer1 = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer: customer1, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage - messagesUsage, + usage: messagesUsage, + }); + + expectCustomerInvoiceCorrect({ + customer: customer1, + count: 1, + latestTotal: 20, + }); + + // Step 3: Convert messages to unlimited + const unlimitedMessagesItem = items.unlimitedMessages(); + + const updateParams2 = { + customer_id: customerId, + product_id: free.id, + items: [unlimitedMessagesItem, priceItem], + }; + + const preview2 = await autumnV1.subscriptions.previewUpdate(updateParams2); + + // No additional charge for unlimited upgrade + expect(preview2.total).toEqual(0); + + await autumnV1.subscriptions.update(updateParams2); + + // Verify final state + const customer2 = await autumnV1.customers.get(customerId); + + // Messages should now be unlimited + expect(customer2.features[TestFeature.Messages].unlimited).toBe(true); + + // Still only 1 invoice (no additional charge for unlimited) + expectCustomerInvoiceCorrect({ + customer: customer2, + count: 2, + latestTotal: 0, // unlimited + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// 1. Update free -> paid -> add feature diff --git a/shared/api/events/list/eventsListParams.ts b/shared/api/events/list/eventsListParams.ts index 656946f35..e749a7853 100644 --- a/shared/api/events/list/eventsListParams.ts +++ b/shared/api/events/list/eventsListParams.ts @@ -4,11 +4,12 @@ import { createPaginationParamsSchema } from "../../common/pagePaginationSchemas export const ApiEventsListParamsSchema = createPaginationParamsSchema({ defaultLimit: 100, }).extend({ - customer_id: z.string().describe("Filter events by customer ID"), + customer_id: z.string().optional().describe("Filter events by customer ID"), feature_id: z .string() .min(1) .or(z.array(z.string().min(1))) + .optional() .describe("Filter by specific feature ID(s)"), custom_range: z diff --git a/shared/utils/common/formatUtils/formatInterval.ts b/shared/utils/common/formatUtils/formatInterval.ts index 615936a0a..61108bc5d 100644 --- a/shared/utils/common/formatUtils/formatInterval.ts +++ b/shared/utils/common/formatUtils/formatInterval.ts @@ -15,11 +15,13 @@ export const formatInterval = ({ }): string => { if (!interval) return ""; - // Handle one_off and lifetime (no interval string) - if ( - interval === BillingInterval.OneOff || - interval === EntInterval.Lifetime - ) { + // Handle one_off (show "one time") + if (interval === BillingInterval.OneOff) { + return "one-off"; + } + + // Handle lifetime (no interval string) + if (interval === EntInterval.Lifetime) { return ""; } diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts index 546dc6972..13925941e 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts @@ -96,27 +96,18 @@ export const isCustomerProductCanceling = (cp?: FullCusProduct) => { * * @param toleranceMs - Tolerance in milliseconds (default: 10 minutes) */ + export const hasCustomerProductEnded = ( cp: FullCusProduct, - params: { nowMs: number; toleranceMs?: number }, + params?: { nowMs?: number }, ) => { - if (!isCustomerProductCanceling(cp)) return false; - if (nullish(cp.ended_at)) return false; - const toleranceMs = params.toleranceMs ?? 10 * 60 * 1000; // 10 minutes default - return cp.ended_at <= params.nowMs + toleranceMs; -}; + const nowMs = params?.nowMs ?? Date.now(); -export const isCustomerProductExpired = ( - cp: FullCusProduct, - params: { nowMs?: number }, -) => { - const nowMs = params.nowMs ?? Date.now(); - - return ( + const hasEnded = isCustomerProductCanceling(cp) && notNullish(cp.ended_at) && - nowMs >= cp.ended_at - ); + nowMs >= cp.ended_at; + return hasEnded; }; export const isCustomerProductTrialing = ( diff --git a/shared/utils/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 3905426bc..637bb8b59 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -1,4 +1,7 @@ -import { FeatureType } from "../models/featureModels/featureEnums.js"; +import { + FeatureType, + FeatureUsageType, +} from "../models/featureModels/featureEnums.js"; import type { Feature } from "../models/featureModels/featureModels.js"; import { Infinite } from "../models/productModels/productEnums.js"; import type { ProductItem } from "../models/productV2Models/productItemModels/productItemModels.js"; @@ -12,40 +15,84 @@ import { } from "./productV2Utils/productItemUtils/getItemType.js"; import { notNullish, nullish } from "./utils.js"; +// ============================================================================ +// Types +// ============================================================================ + +interface DisplayResult { + primary_text: string; + secondary_text?: string; +} + +interface FormatTiersParams { + item: ProductItem; + currency?: string | null; + amountFormatOptions?: Intl.NumberFormatOptions; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +const getIntervalDisplay = (item: ProductItem): string | undefined => { + if (!item.interval) return undefined; + + return formatInterval({ + interval: item.interval, + intervalCount: item.interval_count ?? undefined, + }); +}; + +const getIncludedUsageText = (item: ProductItem, feature: Feature): string => { + const featureName = getFeatureName({ + feature, + units: item.included_usage, + }); + + if (item.included_usage === Infinite) { + return `Unlimited ${featureName}`; + } + + if (nullish(item.included_usage) || item.included_usage === 0) { + return `0 ${featureName}`; + } + + return `${numberWithCommas(item.included_usage)} ${featureName}`; +}; + +const isSingleUseFeature = (feature: Feature): boolean => { + return feature.config?.usage_type === FeatureUsageType.Single; +}; + +// ============================================================================ +// Tier Formatting +// ============================================================================ + export const formatTiers = ({ item, currency, amountFormatOptions, -}: { - item: ProductItem; - currency?: string | null; - amountFormatOptions?: Intl.NumberFormatOptions; -}) => { +}: FormatTiersParams): string | undefined => { const tiers = item.tiers; - if (tiers) { - if (tiers.length === 1) { - return formatAmount({ - currency, - amount: tiers[0].amount, - amountFormatOptions, - }); - } + if (!tiers) return undefined; - const firstPrice = tiers[0].amount; - const lastPrice = tiers[tiers.length - 1].amount; + const format = (amount: number) => + formatAmount({ currency, amount, amountFormatOptions }); - return `${formatAmount({ - currency, - amount: firstPrice, - amountFormatOptions, - })} - ${formatAmount({ - currency, - amount: lastPrice, - amountFormatOptions, - })}`; + if (tiers.length === 1) { + return format(tiers[0].amount); } + + const firstPrice = tiers[0].amount; + const lastPrice = tiers[tiers.length - 1].amount; + + return `${format(firstPrice)} - ${format(lastPrice)}`; }; +// ============================================================================ +// Feature Item Display (no pricing, just entitlement) +// ============================================================================ + export const getFeatureItemDisplay = ({ item, feature, @@ -54,54 +101,30 @@ export const getFeatureItemDisplay = ({ item: ProductItem; feature?: Feature; fullDisplay?: boolean; -}) => { - if (!feature) throw new Error(`Feature ${item.feature_id} not found`); +}): DisplayResult => { + if (!feature) { + // Return fallback display when feature is not found (e.g., during feature ID rename) + return { primary_text: item.feature_id || "Loading..." }; + } + // Boolean features just show the name if (feature.type === FeatureType.Boolean) { return { primary_text: feature.name }; } - const featureName = getFeatureName({ - feature, - units: item.included_usage, - }); + const primaryText = getIncludedUsageText(item, feature); - const includedUsageTxt = - item.included_usage === Infinite - ? "Unlimited " - : nullish(item.included_usage) || item.included_usage === 0 - ? "0 " - : `${numberWithCommas(item.included_usage)} `; - - const intervalStr = formatInterval({ - interval: item.interval ?? undefined, - intervalCount: item.interval_count ?? undefined, - }); - - return { - primary_text: `${includedUsageTxt}${featureName}`, - secondary_text: fullDisplay && intervalStr ? intervalStr : undefined, - }; -}; - -export const getPriceItemDisplay = ({ - item, - currency, -}: { - item: ProductItem; - currency?: string | null; -}) => { - const primaryText = formatAmount({ - currency, - amount: item.price as number, - }); - - const intervalStr = formatInterval({ - interval: item.interval ?? undefined, - intervalCount: item.interval_count ?? undefined, - }); - - const secondaryText = intervalStr || undefined; + // Determine secondary text (interval display) + let secondaryText: string | undefined; + if (fullDisplay) { + const intervalDisplay = getIntervalDisplay(item); + if (intervalDisplay) { + secondaryText = intervalDisplay; + } else if (isSingleUseFeature(feature)) { + // Only show "one-off" for single-use features, not continuous use + secondaryText = "one-off"; + } + } return { primary_text: primaryText, @@ -109,12 +132,39 @@ export const getPriceItemDisplay = ({ }; }; +// ============================================================================ +// Price Item Display (flat price, no feature) +// ============================================================================ + +export const getPriceItemDisplay = ({ + item, + currency, +}: { + item: ProductItem; + currency?: string | null; +}): DisplayResult => { + const primaryText = formatAmount({ + currency, + amount: item.price as number, + }); + + const secondaryText = getIntervalDisplay(item); + + return { + primary_text: primaryText, + secondary_text: secondaryText, + }; +}; + +// ============================================================================ +// Feature + Price Item Display (usage-based pricing) +// ============================================================================ + export const getFeaturePriceItemDisplay = ({ feature, item, currency, isMainPrice = false, - // minifyIncluded = false, amountFormatOptions, fullDisplay = false, }: { @@ -122,71 +172,77 @@ export const getFeaturePriceItemDisplay = ({ item: ProductItem; currency?: string | null; isMainPrice?: boolean; - // minifyIncluded?: boolean; amountFormatOptions?: Intl.NumberFormatOptions; fullDisplay?: boolean; -}) => { +}): DisplayResult => { if (!feature) { throw new Error(`Feature ${item.feature_id} not found`); } - // 1. Get included usage + // Build included usage string (e.g., "100 credits") + const includedUsage = item.included_usage as number | null; + const hasIncludedUsage = notNullish(includedUsage) && includedUsage > 0; + const includedFeatureName = getFeatureName({ feature, units: item.included_usage, }); + const includedUsageStr = hasIncludedUsage + ? `${numberWithCommas(includedUsage)} ${includedFeatureName}` + : ""; - const includedUsage = item.included_usage as number | null; - let includedUsageStr = ""; - if (notNullish(includedUsage) && includedUsage > 0) { - includedUsageStr = `${numberWithCommas(includedUsage)} ${includedFeatureName}`; - } - + // Build price string (e.g., "$0.01") const priceStr = formatTiers({ item, currency, amountFormatOptions }) ?? ""; - // For "per X" display, use singular when billing_units is 1 or not specified + // Build billing unit string (e.g., "credit" or "100 credits") const billingUnits = item.billing_units ?? 1; const billingFeatureName = getFeatureName({ feature, units: billingUnits, }); + const perUnitStr = + billingUnits > 1 + ? `${numberWithCommas(billingUnits)} ${billingFeatureName}` + : billingFeatureName; - let priceStr2 = ""; - if (billingUnits > 1) { - priceStr2 = `${numberWithCommas(billingUnits)} ${billingFeatureName}`; - } else { - priceStr2 = `${billingFeatureName}`; + // Build interval string + const showInterval = isMainPrice || fullDisplay; + let intervalStr = ""; + if (showInterval) { + const intervalDisplay = getIntervalDisplay(item); + if (intervalDisplay) { + intervalStr = intervalDisplay; + } else if (isSingleUseFeature(feature)) { + intervalStr = "one-off"; + } } - // let intervalStr = isMainPrice && item.interval ? ` per ${item.interval}` : ""; - const intervalStr = - isMainPrice || fullDisplay - ? formatInterval({ - interval: item.interval ?? undefined, - intervalCount: item.interval_count ?? undefined, - }) - : ""; - - if (includedUsageStr) { + // Format output based on what we have + if (hasIncludedUsage) { return { primary_text: includedUsageStr, - secondary_text: `then ${priceStr} per ${priceStr2} ${intervalStr}`, + secondary_text: + `then ${priceStr} per ${perUnitStr} ${intervalStr}`.trim(), }; } - if (isMainPrice || fullDisplay) { + if (showInterval) { return { primary_text: priceStr, - secondary_text: `per ${priceStr2} ${intervalStr}`, + secondary_text: `per ${perUnitStr} ${intervalStr}`.trim(), }; } return { - primary_text: `${priceStr} per ${priceStr2} ${intervalStr}`, + primary_text: `${priceStr} per ${perUnitStr}`.trim(), secondary_text: undefined, }; }; +// ============================================================================ +// Main Entry Point +// ============================================================================ + export const getProductItemDisplay = ({ item, features, @@ -199,26 +255,25 @@ export const getProductItemDisplay = ({ currency?: string | null; fullDisplay?: boolean; amountFormatOptions?: Intl.NumberFormatOptions; -}) => { +}): DisplayResult => { + const findFeature = () => features.find((f) => f.id === item.feature_id); + if (isFeatureItem(item)) { return getFeatureItemDisplay({ item, - feature: features.find((f) => f.id === item.feature_id), + feature: findFeature(), fullDisplay, }); } if (isPriceItem(item)) { - return getPriceItemDisplay({ - item, - currency, - }); + return getPriceItemDisplay({ item, currency }); } if (isFeaturePriceItem(item)) { return getFeaturePriceItemDisplay({ item, - feature: features.find((f) => f.id === item.feature_id), + feature: findFeature(), currency, fullDisplay, amountFormatOptions, diff --git a/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts b/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts index f2401e365..ed0fb3277 100644 --- a/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts +++ b/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts @@ -103,6 +103,12 @@ const tiersAreSame = ( ); }; +// Helper to normalize included_usage for comparison (null and 0 are equivalent) +const normalizeIncludedUsage = (value: number | "inf" | null | undefined) => { + if (value === null || value === undefined) return 0; + return value; +}; + export const featureItemsAreSame = ({ item1, item2, @@ -118,7 +124,10 @@ export const featureItemsAreSame = ({ message: `Feature ID different: ${item1.feature_id} != ${item2.feature_id}`, }, included_usage: { - condition: item1.included_usage == item2.included_usage, + // Normalize null/undefined to 0 for comparison since they're semantically equivalent + condition: + normalizeIncludedUsage(item1.included_usage) == + normalizeIncludedUsage(item2.included_usage), message: `Included usage different: ${item1.included_usage} != ${item2.included_usage}`, }, interval: { @@ -232,7 +241,10 @@ export const featurePriceItemsAreSame = ({ // console.log("Item 2 config:", item2.config); const entsSame = { included_usage: { - condition: item1.included_usage == item2.included_usage, + // Normalize null/undefined to 0 for comparison since they're semantically equivalent + condition: + normalizeIncludedUsage(item1.included_usage) == + normalizeIncludedUsage(item2.included_usage), message: `Included usage different: ${item1.included_usage} != ${item2.included_usage}`, }, usage_limit: { diff --git a/shared/utils/productV2Utils/compareProductUtils/generateTrialChanges.ts b/shared/utils/productV2Utils/compareProductUtils/generateTrialChanges.ts index 8303c0e3c..4cf28e629 100644 --- a/shared/utils/productV2Utils/compareProductUtils/generateTrialChanges.ts +++ b/shared/utils/productV2Utils/compareProductUtils/generateTrialChanges.ts @@ -27,11 +27,13 @@ export function generateTrialChanges({ removeTrial, trialLength, trialDuration, + trialEnabled = true, }: { customerProduct: FullCusProduct; removeTrial: boolean; trialLength: number | null; trialDuration: FreeTrialDuration; + trialEnabled?: boolean; }): ItemEdit[] { const isCurrentlyTrialing = isCustomerProductTrialing(customerProduct); const remainingDays = getRemainingTrialDays({ @@ -53,6 +55,11 @@ export function generateTrialChanges({ return changes; } + // If trial is not enabled (collapsed), don't generate changes for new trials + if (!trialEnabled && !isCurrentlyTrialing) { + return changes; + } + if (!removeTrial && trialLength !== null && trialLength > 0) { const newTrialDays = getTrialLengthInDays({ trialLength, trialDuration }); diff --git a/vite/src/app/layout.tsx b/vite/src/app/layout.tsx index 8978b3658..8ba54b124 100644 --- a/vite/src/app/layout.tsx +++ b/vite/src/app/layout.tsx @@ -2,10 +2,11 @@ import { AppEnv } from "@autumn/shared"; import { ArrowRightIcon } from "@phosphor-icons/react"; import { AutumnProvider } from "autumn-js/react"; import { NuqsAdapter } from "nuqs/adapters/react-router/v7"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Outlet, useNavigate } from "react-router"; import { CustomToaster } from "@/components/general/CustomToaster"; import { IconButton } from "@/components/v2/buttons/IconButton"; +import { PortalContainerContext } from "@/contexts/PortalContainerContext"; import { useAutumnFlags } from "@/hooks/common/useAutumnFlags"; import { useGlobalErrorHandler } from "@/hooks/common/useGlobalErrorHandler"; import { useOrg } from "@/hooks/common/useOrg"; @@ -28,6 +29,7 @@ export function MainLayout() { const { data, isPending } = useSession(); const { org, isLoading: orgLoading } = useOrg(); const { handleApiError } = useGlobalErrorHandler(); + const containerRef = useRef(null); const navigate = useNavigate(); @@ -90,25 +92,31 @@ export function MainLayout() { return ( -
- - - - - {/* */} - -
+ +
+ + + + + {/* */} + +
+
); } -const MainContent = () => { +const MainContent = ({ + containerRef, +}: { + containerRef: React.RefObject; +}) => { const env = useEnv(); const { org } = useOrg(); const [showDeployDialog, setShowDeployDialog] = useState(false); @@ -128,7 +136,10 @@ const MainContent = () => { "font-normal", )} > -
+
{env === AppEnv.Sandbox && (

You're in sandbox

@@ -159,17 +170,6 @@ const MainContent = () => {
- {/*
-
-

- Autumn is coming to mobile soon -

-

- We're currently designed for larger screens. Come back on - your desktop? -

-
-
*/}
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 a584a8bfe..aec3f42d3 100644 --- a/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx @@ -8,11 +8,12 @@ import { UsageModel, } from "@autumn/shared"; import { PencilSimpleIcon } from "@phosphor-icons/react"; +import { LayoutGroup, motion } from "motion/react"; import { useMemo } from "react"; import { Button } from "@/components/v2/buttons/Button"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; import { useOrg } from "@/hooks/common/useOrg"; -import { cn } from "@/lib/utils"; +import { LAYOUT_TRANSITION } from "../constants/animationConstants"; import { useUpdateSubscriptionFormContext } from "../context/UpdateSubscriptionFormProvider"; import { PriceDisplay } from "./PriceDisplay"; import { SectionTitle } from "./SectionTitle"; @@ -82,19 +83,30 @@ export function EditPlanSection() { }, }); - const oldIntervalText = originalInterval - ? formatInterval({ - interval: originalInterval, - intervalCount: originalIntervalCount, - }) - : null; + const getIntervalText = ( + interval: typeof originalInterval, + intervalCount: number, + hasPriceItem: boolean, + ) => { + 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; + }; - const newIntervalText = currentInterval - ? formatInterval({ - interval: currentInterval, - intervalCount: currentIntervalCount, - }) - : (oldIntervalText ?? "per month"); + const oldIntervalText = getIntervalText( + originalInterval, + originalIntervalCount, + !!originalPriceItem, + ); + + const newIntervalText = getIntervalText( + currentInterval, + currentIntervalCount, + !!currentPriceItem, + ); return { oldPrice: formatPrice(originalPrice), @@ -144,93 +156,113 @@ export function EditPlanSection() { )}
-
- {product?.items?.map((item: ProductItem, index: number) => { - if (!item.feature_id) return null; + +
+ {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 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 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, - }); + 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 && ( - - )} -
+ + + ); + })} + {deletedItems.map((item: ProductItem, index: number) => ( + + + + ))} + {showVersionChange && ( + + + )} - > -
- trialState.setIsTrialExpanded(false)} - onRevert={trialState.handleRevertTrial} - /> -
+ {(trialState.isTrialExpanded || trialState.removeTrial) && ( + + trialState.setIsTrialExpanded(false)} + onRevert={trialState.handleRevertTrial} + /> + + )} + + +
-
+
- ) : null} - + ) : ( + + )} ); } diff --git a/vite/src/components/forms/update-subscription-v2/components/SectionTitle.tsx b/vite/src/components/forms/update-subscription-v2/components/SectionTitle.tsx index b08fb3cfb..6a0b85281 100644 --- a/vite/src/components/forms/update-subscription-v2/components/SectionTitle.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/SectionTitle.tsx @@ -45,7 +45,7 @@ export function SectionTitle({ const showTrialToggle = !trialState.isCurrentlyTrialing; const trialIsActive = - (trialState.isCurrentlyTrialing || trialState.hasTrialValue) && + (trialState.isCurrentlyTrialing || trialState.isTrialExpanded) && !trialState.removeTrial; return ( 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 ecd8ba6a4..86f65f366 100644 --- a/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx @@ -9,6 +9,7 @@ import { CheckIcon, PencilSimpleIcon, } from "@phosphor-icons/react"; +import { AnimatePresence, motion } from "motion/react"; import { useState } from "react"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { @@ -21,6 +22,7 @@ import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { cn } from "@/lib/utils"; import { PlanFeatureIcon } from "@/views/products/plan/components/plan-card/PlanFeatureIcon"; import { CustomDotIcon } from "@/views/products/plan/components/plan-card/PlanFeatureRow"; +import { FAST_TRANSITION } from "../constants/animationConstants"; import type { UseUpdateSubscriptionForm } from "../hooks/useUpdateSubscriptionForm"; import { getEditIcon } from "../utils/getEditIcon"; import { getItemRingClass } from "../utils/ringClassUtils"; @@ -243,42 +245,64 @@ export function SubscriptionItemRow({ (showPrepaidOutside || hasEditableEdit) && form && featureId && ( -
- {isEditingQuantity ? ( - <> - - {(field) => ( - - )} - - } - variant="skeleton" - size="sm" - className="text-t4 hover:text-t2 hover:bg-muted" - onClick={() => setIsEditingQuantity(false)} - /> - - ) : ( - <> - - x{prepaidQuantity ?? 0} - - - - } - variant="skeleton" - size="sm" - className="text-t4 hover:text-t2 hover:bg-muted" - onClick={() => setIsEditingQuantity(true)} - /> - - Update prepaid quantity - - - )} -
+ + + {isEditingQuantity ? ( + + + {(field) => ( + + )} + + } + variant="skeleton" + size="sm" + className="text-green-600 dark:text-green-500 hover:text-green-700! dark:hover:text-green-400! hover:bg-black/5 dark:hover:bg-white/10" + onClick={() => setIsEditingQuantity(false)} + /> + + ) : ( + + + x{prepaidQuantity ?? 0} + + + + } + variant="skeleton" + size="sm" + className="text-t4 hover:text-t2 hover:bg-muted" + onClick={() => setIsEditingQuantity(true)} + /> + + Update prepaid quantity + + + )} + + )}
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 e3e168a0a..72e85a21f 100644 --- a/vite/src/components/forms/update-subscription-v2/components/TrialEditorRow.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/TrialEditorRow.tsx @@ -2,10 +2,12 @@ import { type FreeTrialDuration, getTrialLengthInDays } from "@autumn/shared"; import { ArrowCounterClockwiseIcon, CalendarBlankIcon, + CheckIcon, PencilSimpleIcon, TrashIcon, } from "@phosphor-icons/react"; import { useStore } from "@tanstack/react-form"; +import { AnimatePresence, motion } from "motion/react"; import { useRef, useState } from "react"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { @@ -14,6 +16,7 @@ import { TooltipTrigger, } from "@/components/v2/tooltips/Tooltip"; import { cn } from "@/lib/utils"; +import { FAST_TRANSITION } from "../constants/animationConstants"; import { formatTrialDuration, TRIAL_DURATION_OPTIONS, @@ -139,11 +142,10 @@ export function TrialEditorRow({ ); } - if ( - !isEditing && - !isAddingNewTrial && - (hasTrialValue || isCurrentlyTrialing) - ) { + const showDisplayMode = + !isEditing && !isAddingNewTrial && (hasTrialValue || isCurrentlyTrialing); + + if (showDisplayMode) { return (
Free Trial
@@ -178,38 +178,55 @@ export function TrialEditorRow({ ) : null}
-
- } - variant="skeleton" - size="sm" - className="text-t4 hover:text-t2 hover:bg-muted" - onClick={() => { - setIsEditing(true); - setIsAddingNewTrial(false); - }} - /> -
+ + + + } + variant="skeleton" + size="sm" + className="text-t4 hover:text-t2 hover:bg-muted" + onClick={() => { + setIsEditing(true); + setIsAddingNewTrial(false); + }} + /> + + + ); } const handleClearTrial = () => { - if (!hasTrialValue && !isCurrentlyTrialing) { - onCollapse(); - setIsEditing(false); - setIsAddingNewTrial(true); - return; - } + // Just collapse - preserve the value so it can be restored + onCollapse(); + setIsEditing(false); + setIsAddingNewTrial(true); - form.setFieldValue("trialLength", null); + // Only mark for removal if they're currently trialing (ending an active trial) if (isCurrentlyTrialing) { onEndTrial(); } - setIsEditing(false); - setIsAddingNewTrial(true); }; + const isNewTrial = !isCurrentlyTrialing; + const editModeRingClass = isNewTrial + ? "ring-1 ring-inset ring-green-500/50" + : "ring-1 ring-inset ring-amber-500/50"; + return (
Free Trial
-
- - {(field) => ( - + + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + } + variant="skeleton" + size="sm" + className="text-green-600 dark:text-green-500 hover:text-green-700! dark:hover:text-green-400! hover:bg-black/5 dark:hover:bg-white/10" + onClick={() => { + if (hasTrialValue) { + setIsEditing(false); + setIsAddingNewTrial(false); + } else { + handleClearTrial(); + } + }} /> - )} - - - {(field) => ( - } + variant="skeleton" + size="sm" + className="text-t4 hover:text-red-400!" + onClick={handleClearTrial} /> - )} - - } - variant="skeleton" - size="sm" - className="text-t4 hover:text-red-400" - onClick={handleClearTrial} - /> -
+ + + ); } diff --git a/vite/src/components/forms/update-subscription-v2/constants/animationConstants.ts b/vite/src/components/forms/update-subscription-v2/constants/animationConstants.ts new file mode 100644 index 000000000..308309694 --- /dev/null +++ b/vite/src/components/forms/update-subscription-v2/constants/animationConstants.ts @@ -0,0 +1,16 @@ +import type { Transition } from "motion/react"; + +export const FAST_TRANSITION: Transition = { + duration: 0.1, + ease: [0.32, 0.72, 0, 1], +}; + +export const TRANSITION: Transition = { + duration: 0.2, + ease: [0.32, 0.72, 0, 1], +}; + +export const LAYOUT_TRANSITION: Transition = { + duration: 0.35, + ease: [0.32, 0.72, 0, 1], +}; diff --git a/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx b/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx index 5a3da2653..d2c048187 100644 --- a/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx +++ b/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx @@ -172,6 +172,7 @@ export function UpdateSubscriptionFormProvider({ removeTrial: formValues.removeTrial, trialLength: formValues.trialLength, trialDuration: formValues.trialDuration, + trialEnabled: formValues.trialEnabled, }); const freeTrialValue = freeTrial === null ? undefined : (freeTrial ?? base.free_trial); @@ -187,6 +188,7 @@ export function UpdateSubscriptionFormProvider({ formValues.removeTrial, formValues.trialLength, formValues.trialDuration, + formValues.trialEnabled, ]); const hasBillingChanges = useHasBillingChanges({ @@ -202,6 +204,7 @@ export function UpdateSubscriptionFormProvider({ removeTrial: formValues.removeTrial, trialLength: formValues.trialLength, trialDuration: formValues.trialDuration, + trialEnabled: formValues.trialEnabled, }); const previewQuery = useUpdateSubscriptionPreview({ diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts b/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts index 7dd5a3a6d..86ee45031 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts @@ -34,6 +34,7 @@ export function useHasSubscriptionChanges({ removeTrial: formValues.removeTrial, trialLength: formValues.trialLength, trialDuration: formValues.trialDuration, + trialEnabled: formValues.trialEnabled, }); if (trialChanges.length > 0) return true; @@ -74,6 +75,7 @@ export function useHasSubscriptionChanges({ formValues.removeTrial, formValues.trialLength, formValues.trialDuration, + formValues.trialEnabled, formValues.version, formValues.items, formValues.prepaidOptions, diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useTrialState.ts b/vite/src/components/forms/update-subscription-v2/hooks/useTrialState.ts index 710a3bf67..47e89b926 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useTrialState.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useTrialState.ts @@ -7,7 +7,7 @@ import { isCustomerProductTrialing, } from "@autumn/shared"; import { useStore } from "@tanstack/react-form"; -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import type { UseUpdateSubscriptionForm } from "./useUpdateSubscriptionForm"; interface UseTrialStateParams { @@ -54,7 +54,10 @@ export function useTrialState({ ? formatRemainingTrialTime({ trialEndsAt: customerProduct.trial_ends_at }) : null; - const [isTrialExpanded, setIsTrialExpanded] = useState(isCurrentlyTrialing); + const trialEnabled = useStore( + form.store, + (state) => state.values.trialEnabled, + ); const removeTrial = useStore(form.store, (state) => state.values.removeTrial); @@ -80,22 +83,31 @@ export function useTrialState({ const handleToggleTrial = useCallback(() => { if (removeTrial) { form.setFieldValue("removeTrial", false); - setIsTrialExpanded(true); + form.setFieldValue("trialEnabled", true); } else { - setIsTrialExpanded((prev) => !prev); + form.setFieldValue("trialEnabled", !trialEnabled); } - }, [removeTrial, form]); + }, [removeTrial, trialEnabled, form]); const handleEndTrial = useCallback(() => { form.setFieldValue("removeTrial", true); + form.setFieldValue("trialEnabled", false); }, [form]); const handleRevertTrial = useCallback(() => { form.setFieldValue("removeTrial", false); + form.setFieldValue("trialEnabled", true); form.setFieldValue("trialLength", remainingTrialDays); form.setFieldValue("trialDuration", FreeTrialDuration.Day); }, [form, remainingTrialDays]); + const setIsTrialExpanded = useCallback( + (expanded: boolean) => { + form.setFieldValue("trialEnabled", expanded); + }, + [form], + ); + return { isCurrentlyTrialing, remainingTrialDays, @@ -105,7 +117,7 @@ export function useTrialState({ removeTrial, hasTrialValue, isTrialModified, - isTrialExpanded, + isTrialExpanded: trialEnabled, handleToggleTrial, handleEndTrial, handleRevertTrial, diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts index 642fb1631..4bfbb2b43 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts @@ -49,6 +49,7 @@ export function useUpdateSubscriptionForm({ trialLength: remainingTrialDays, trialDuration: FreeTrialDuration.Day, removeTrial: false, + trialEnabled: isTrialing, version: currentVersion, items: null, } as UpdateSubscriptionForm, diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts index dc63c233c..ced9f2560 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts @@ -25,6 +25,7 @@ export function useUpdateSubscriptionRequestBody({ trialLength, trialDuration, removeTrial, + trialEnabled, version, items, } = formValues; @@ -89,6 +90,7 @@ export function useUpdateSubscriptionRequestBody({ removeTrial, trialLength, trialDuration, + trialEnabled, }); if (freeTrial !== undefined) { requestBody.free_trial = freeTrial; diff --git a/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts b/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts index c25cf2dcf..e5a5f6786 100644 --- a/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts +++ b/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts @@ -7,6 +7,7 @@ export const UpdateSubscriptionFormSchema = z.object({ trialLength: z.number().positive().nullable(), trialDuration: z.enum(FreeTrialDuration), removeTrial: z.boolean(), + trialEnabled: z.boolean(), version: z.number().positive(), diff --git a/vite/src/components/forms/update-subscription-v2/utils/getFreeTrial.ts b/vite/src/components/forms/update-subscription-v2/utils/getFreeTrial.ts index a4a757afc..d13043380 100644 --- a/vite/src/components/forms/update-subscription-v2/utils/getFreeTrial.ts +++ b/vite/src/components/forms/update-subscription-v2/utils/getFreeTrial.ts @@ -7,12 +7,15 @@ export function getFreeTrial({ removeTrial, trialLength, trialDuration, + trialEnabled, }: { removeTrial: boolean; trialLength: number | null; trialDuration: FreeTrialDuration; + trialEnabled: boolean; }): CreateFreeTrial | null | undefined { if (removeTrial) return null; + if (!trialEnabled) return undefined; if (trialLength !== null && trialLength > 0) { return { length: trialLength, diff --git a/vite/src/components/general/form/fields/number-field.tsx b/vite/src/components/general/form/fields/number-field.tsx index 1caf112c3..d735d5ba5 100644 --- a/vite/src/components/general/form/fields/number-field.tsx +++ b/vite/src/components/general/form/fields/number-field.tsx @@ -10,6 +10,7 @@ export function NumberField({ min, max, className, + inputClassName, hideFieldInfo, disabled, }: { @@ -18,6 +19,7 @@ export function NumberField({ min?: number; max?: number; className?: string; + inputClassName?: string; hideFieldInfo?: boolean; disabled?: boolean; }) { @@ -51,7 +53,7 @@ export function NumberField({ placeholder={placeholder} value={field.state.value ?? ""} onChange={handleChange} - className="text-sm" + className={cn("text-sm", inputClassName)} disabled={disabled} /> {!hideFieldInfo && } diff --git a/vite/src/components/general/table/table-content.tsx b/vite/src/components/general/table/table-content.tsx index 80fc12417..8e1e57c0d 100644 --- a/vite/src/components/general/table/table-content.tsx +++ b/vite/src/components/general/table/table-content.tsx @@ -19,9 +19,9 @@ export function TableContent({ return (
diff --git a/vite/src/components/general/table/table-header.tsx b/vite/src/components/general/table/table-header.tsx index 306869073..74400b5f5 100644 --- a/vite/src/components/general/table/table-header.tsx +++ b/vite/src/components/general/table/table-header.tsx @@ -14,7 +14,7 @@ function SortIcon({ sortDirection }: { sortDirection: string | false }) { return (