diff --git a/bun.lock b/bun.lock index 70e526589..0c4991349 100644 --- a/bun.lock +++ b/bun.lock @@ -161,7 +161,6 @@ "version": "1.0.0", "dependencies": { "@date-fns/utc": "catalog:", - "@owpz/ksuid": "^25.7.20", "date-fns": "^4.1.0", "decimal.js": "^10.5.0", "dotenv": "^16.5.0", @@ -1039,8 +1038,6 @@ "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], - "@owpz/ksuid": ["@owpz/ksuid@25.7.20", "", { "dependencies": { "base-x": "^5.0.0" }, "bin": { "ksuid": "dist/cli.js" } }, "sha512-cTe0yLCXtKvVl7wE3E3r+oisL2vhrMy+OZaVN53gsPGc4oY7eyyobBb/MO7UUkYAbPjt+9n20eS/zudelQlVHA=="], - "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], @@ -1891,8 +1888,6 @@ "base-convert-int-array": ["base-convert-int-array@1.0.1", "", {}, "sha512-NWqzaoXx8L/SS32R+WmKqnQkVXVYl2PwNJ68QV3RAlRRL1uV+yxJT66abXI1cAvqCXQTyXr7/9NN4Af90/zDVw=="], - "base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="], - "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], diff --git a/server/src/honoMiddlewares/analyticsMiddleware.ts b/server/src/honoMiddlewares/analyticsMiddleware.ts index 35d04c211..651ecced3 100644 --- a/server/src/honoMiddlewares/analyticsMiddleware.ts +++ b/server/src/honoMiddlewares/analyticsMiddleware.ts @@ -115,7 +115,9 @@ const logResponse = async ({ res: responseBody, }); - ctx.logger.debug(`EXTRA LOGS:`, JSON.stringify(ctx.extraLogs, null, 2)); + if (Object.keys(ctx.extraLogs).length > 0) { + ctx.logger.debug(`EXTRA LOGS:`, JSON.stringify(ctx.extraLogs, null, 2)); + } } catch (error) { console.error("Failed to log response to logtail"); console.error(error); diff --git a/server/src/initHono.ts b/server/src/initHono.ts index 9a5f00d67..180deed7e 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -12,6 +12,7 @@ import type { HonoEnv } from "./honoUtils/HonoEnv.js"; import { handleHealthCheck } from "./honoUtils/handleHealthCheck.js"; import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js"; import { apiRouter } from "./routers/apiRouter.js"; +import { cliRouter } from "./routers/cliRouter.js"; import { internalRouter } from "./routers/internalRouter.js"; import { auth } from "./utils/auth.js"; @@ -102,6 +103,7 @@ export const createHonoApp = () => { // API Middleware app.route("/v1", apiRouter); + app.route("", cliRouter); app.route("", internalRouter); app.onError(errorMiddleware); diff --git a/server/src/internal/billing/v2/setup/setupTrialContext.ts b/server/src/internal/billing/v2/setup/setupTrialContext.ts index 59a917a46..ce0f85f58 100644 --- a/server/src/internal/billing/v2/setup/setupTrialContext.ts +++ b/server/src/internal/billing/v2/setup/setupTrialContext.ts @@ -5,7 +5,6 @@ import type { } from "@autumn/shared"; import { addDuration, - initFreeTrial, isCustomerProductTrialing, isProductPaidAndRecurring, secondsToMs, @@ -13,6 +12,7 @@ import { import type Stripe from "stripe"; import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import type { TrialContext } from "@/internal/billing/v2/billingContext"; +import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial"; export const setupTrialContext = ({ stripeSubscription, diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice.ts index b060494a1..bc9fcf64f 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice.ts @@ -1,9 +1,5 @@ -import { - type CustomerPrice, - type FullCustomer, - generateId, - type Price, -} from "@autumn/shared"; +import type { CustomerPrice, FullCustomer, Price } from "@autumn/shared"; +import { generateId } from "@/utils/genUtils"; export const initCustomerPrice = ({ price, diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts index b329a7d06..270551d96 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts @@ -2,11 +2,11 @@ import { CollectionMethod, type CusProduct, CusProductStatus, - generateId, type InitFullCustomerProductContext, type InitFullCustomerProductOptions, notNullish, } from "@autumn/shared"; +import { generateId } from "@/utils/genUtils"; export const initCustomerProduct = ({ initContext, diff --git a/server/src/internal/customers/CusSearchService.ts b/server/src/internal/customers/CusSearchService.ts index 7c1a4ed66..ae97f6228 100644 --- a/server/src/internal/customers/CusSearchService.ts +++ b/server/src/internal/customers/CusSearchService.ts @@ -1,24 +1,24 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; - -import { AppEnv, customers, CusProductStatus } from "@autumn/shared"; +import { + type AppEnv, + CusProductStatus, + customerProducts, + customers, + products, +} from "@autumn/shared"; import { and, desc, eq, - ilike, - or, - lt, - isNotNull, - gt, - sql, - gte, + gt, ilike, isNotNull, isNull, - inArray, + lt, notExists, + or, + sql } from "drizzle-orm"; -import { customerProducts, products } from "@autumn/shared"; import { alias } from "drizzle-orm/pg-core"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; // Create alias for subquery const customerProductsAlias = alias(customerProducts, "cp_alias"); @@ -111,7 +111,7 @@ export class CusSearchService { let statuses: string[] = []; // 1. Create base query to fetch all customerproducts - let activeProdFilter = or( + const activeProdFilter = or( eq(customerProducts.status, CusProductStatus.Active), eq(customerProducts.status, CusProductStatus.PastDue), ); @@ -135,7 +135,7 @@ export class CusSearchService { }); } - let filtersDrizzle = and( + const filtersDrizzle = and( // New product:version filtering productVersionFilters.length > 0 ? or( @@ -206,7 +206,7 @@ export class CusSearchService { : undefined, ); - let cusFilter = and( + const cusFilter = and( eq(customers.org_id, orgId), eq(customers.env, env), @@ -501,7 +501,7 @@ export class CusSearchService { const noneProducts = filters?.none === "true"; if (noneProducts) { - return await this.searchByNone({ + return await CusSearchService.searchByNone({ db, orgId, env, @@ -514,7 +514,7 @@ export class CusSearchService { } if (filters?.version && filters?.version.length > 0) { - return await this.searchByProduct({ + return await CusSearchService.searchByProduct({ db, orgId, env, @@ -526,7 +526,7 @@ export class CusSearchService { }); } - let filterClause = and( + const filterClause = and( eq(customers.org_id, orgId), eq(customers.env, env), search diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index 68e480e9a..549a61907 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -1,220 +1,20 @@ -import { - CusExpand, - CusProductStatus, - cusProductToProduct, - ErrCode, - productToCusProduct, -} from "@autumn/shared"; -import { Router } from "express"; import { Hono } from "hono"; -import { StatusCodes } from "http-status-codes"; -import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { EventService } from "../api/events/EventService.js"; -import { ProductService } from "../products/ProductService.js"; -import { mapToProductV2 } from "../products/productV2Utils.js"; -import { CusBatchService } from "./CusBatchService.js"; -import { CusSearchService } from "./CusSearchService.js"; -import { CusService } from "./CusService.js"; -import { ACTIVE_STATUSES } from "./cusProducts/CusProductService.js"; +import { handleGetCustomer } from "@/internal/customers/internalHandlers/handleGetCustomer.js"; import { handleGetCusReferrals } from "./internalHandlers/handleGetCusReferrals.js"; - -export const cusRouter: Router = Router(); - -cusRouter.post("/all/search", (req, res) => - routeHandler({ - req, - res, - action: "search customers", - handler: async (req, res) => { - const { search, page_size = 50, page = 1, last_item, filters } = req.body; - - const { data: customers, count } = await CusSearchService.search({ - db: req.db, - orgId: req.orgId, - env: req.env, - search, - filters, - lastItem: last_item, - pageNumber: page, - pageSize: page_size, - }); - - res.status(200).json({ customers, totalCount: Number(count) }); - }, - }), -); - -cusRouter.get("/:customer_id/events", async (req: any, res: any) => { - try { - const { db, org, features, env } = req; - const { customer_id } = req.params; - const orgId = req.orgId; - - const customer = await CusService.get({ - db, - orgId, - env, - idOrInternalId: customer_id, - }); - - if (!customer) { - throw new RecaseError({ - message: "Customer not found", - code: ErrCode.CustomerNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - const events = await EventService.getByCustomerId({ - db, - internalCustomerId: customer.internal_id, - env, - orgId: orgId, - }); - - res.status(200).json({ events }); - } catch (error) { - handleFrontendReqError({ req, error, res, action: "get customer events" }); - } -}); - -cusRouter.post("/all/full_customers", async (req: any, res: any) => - routeHandler({ - req, - res, - action: "get customer full customers", - handler: async (req, res) => { - const { db, org, env } = req; - const { search, page_size = 50, page = 1, last_item, filters } = req.body; - - const { data: customers, count } = await CusSearchService.search({ - db: req.db, - orgId: req.orgId, - env: req.env, - search, - filters, - lastItem: last_item, - pageNumber: page, - pageSize: page_size, - }); - - const fullCustomers = await CusBatchService.getByInternalIds({ - db, - org, - env, - internalCustomerIds: customers.map( - (customer: any) => customer.internal_id, - ), - }); - - res.status(200).json({ fullCustomers }); - }, - }), -); - -cusRouter.get( - "/:customer_id/product/:product_id", - async (req: any, res: any) => { - try { - const { org, env, db, features, logger } = req; - const { customer_id, product_id } = req.params; - const { version, customer_product_id, entity_id } = req.query; - - const customer = await CusService.getFull({ - db, - orgId: org.id, - env, - idOrInternalId: customer_id, - withEntities: true, - entityId: entity_id, - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.PastDue, - CusProductStatus.Scheduled, - CusProductStatus.Expired, - ], - }); - - if (!customer) { - throw new RecaseError({ - message: "Customer not found", - code: "CUSTOMER_NOT_FOUND", - statusCode: StatusCodes.NOT_FOUND, - }); - } - - const cusProducts = customer.customer_products; - const entity = customer.entity; - - const cusProduct = productToCusProduct({ - cusProducts, - productId: product_id, - internalEntityId: entity?.internal_id, - version: version ? parseInt(version) : undefined, - cusProductId: customer_product_id, - inStatuses: ACTIVE_STATUSES, - }); - - const product = cusProduct - ? cusProductToProduct({ cusProduct }) - : await ProductService.getFull({ - db, - orgId: org.id, - env, - idOrInternalId: product_id, - version: - version && Number.isInteger(parseInt(version)) - ? parseInt(version) - : undefined, - }); - - const productV2 = mapToProductV2({ product: product!, features }); - - res.status(200).json({ - cusProduct, - product: productV2, - }); - } catch (error) { - handleFrontendReqError({ - req, - error, - res, - action: "get customer product", - }); - } - }, -); +import { handleGetCustomerEvents } from "./internalHandlers/handleGetCustomerEvents.js"; +import { handleGetCustomerProduct } from "./internalHandlers/handleGetCustomerProduct.js"; +import { handleGetFullCustomers } from "./internalHandlers/handleGetFullCustomers.js"; +import { handleSearchCustomers } from "./internalHandlers/handleSearchCustomers.js"; export const internalCusRouter = new Hono(); -export const handleGetCustomerInternal = createRoute({ - handler: async (c) => { - const { db, org, env } = c.get("ctx"); - const { customer_id } = c.req.param(); - - const fullCus = await CusService.getFull({ - db, - orgId: org.id, - env, - idOrInternalId: customer_id, - withEntities: true, - expand: [CusExpand.Invoices], - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.PastDue, - CusProductStatus.Scheduled, - CusProductStatus.Expired, - ], - }); - - return c.json({ - customer: fullCus, - }); - }, -}); - -internalCusRouter.get("/:customer_id", ...handleGetCustomerInternal); +internalCusRouter.post("/all/search", ...handleSearchCustomers); +internalCusRouter.post("/all/full_customers", ...handleGetFullCustomers); +internalCusRouter.get("/:customer_id", ...handleGetCustomer); +internalCusRouter.get("/:customer_id/events", ...handleGetCustomerEvents); +internalCusRouter.get( + "/:customer_id/product/:product_id", + ...handleGetCustomerProduct, +); internalCusRouter.get("/:customer_id/referrals", ...handleGetCusReferrals); diff --git a/server/src/internal/customers/internalHandlers/handleGetCustomer.ts b/server/src/internal/customers/internalHandlers/handleGetCustomer.ts new file mode 100644 index 000000000..e13f3d106 --- /dev/null +++ b/server/src/internal/customers/internalHandlers/handleGetCustomer.ts @@ -0,0 +1,32 @@ +import { CusExpand, CusProductStatus } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { CusService } from "@/internal/customers/CusService"; + +/** + * Internal route for get full customer object + */ +export const handleGetCustomer = createRoute({ + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + const { customer_id } = c.req.param(); + + const fullCus = await CusService.getFull({ + db, + orgId: org.id, + env, + idOrInternalId: customer_id, + withEntities: true, + expand: [CusExpand.Invoices], + inStatuses: [ + CusProductStatus.Active, + CusProductStatus.PastDue, + CusProductStatus.Scheduled, + CusProductStatus.Expired, + ], + }); + + return c.json({ + customer: fullCus, + }); + }, +}); diff --git a/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts b/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts new file mode 100644 index 000000000..54280c1a6 --- /dev/null +++ b/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts @@ -0,0 +1,35 @@ +import { CustomerNotFoundError } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { EventService } from "@/internal/api/events/EventService"; +import { CusService } from "../CusService"; + +/** + * GET /customers/:customer_id/events + * Used by: vite/src/views/customers/customer/hooks/useCusEventsQuery.tsx + */ +export const handleGetCustomerEvents = createRoute({ + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + const { customer_id } = c.req.param(); + + const customer = await CusService.get({ + db, + orgId: org.id, + env, + idOrInternalId: customer_id, + }); + + if (!customer) { + throw new CustomerNotFoundError({ customerId: customer_id }); + } + + const events = await EventService.getByCustomerId({ + db, + internalCustomerId: customer.internal_id, + env, + orgId: org.id, + }); + + return c.json({ events }); + }, +}); diff --git a/server/src/internal/customers/internalHandlers/handleGetCustomerProduct.ts b/server/src/internal/customers/internalHandlers/handleGetCustomerProduct.ts new file mode 100644 index 000000000..1471a9959 --- /dev/null +++ b/server/src/internal/customers/internalHandlers/handleGetCustomerProduct.ts @@ -0,0 +1,80 @@ +import { + CusProductStatus, + CustomerNotFoundError, + cusProductToProduct, + productToCusProduct, +} from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { ProductService } from "@/internal/products/ProductService"; +import { mapToProductV2 } from "@/internal/products/productV2Utils"; +import { CusService } from "../CusService"; +import { ACTIVE_STATUSES } from "../cusProducts/CusProductService"; + +/** + * GET /customers/:customer_id/product/:product_id + * Used by: vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx + */ +export const handleGetCustomerProduct = createRoute({ + query: z.object({ + version: z.string().optional(), + customer_product_id: z.string().optional(), + entity_id: z.string().optional(), + }), + handler: async (c) => { + const { db, org, env, features } = c.get("ctx"); + const { customer_id, product_id } = c.req.param(); + const { version, customer_product_id, entity_id } = c.req.valid("query"); + + const customer = await CusService.getFull({ + db, + orgId: org.id, + env, + idOrInternalId: customer_id, + withEntities: true, + entityId: entity_id, + inStatuses: [ + CusProductStatus.Active, + CusProductStatus.PastDue, + CusProductStatus.Scheduled, + CusProductStatus.Expired, + ], + }); + + if (!customer) { + throw new CustomerNotFoundError({ customerId: customer_id }); + } + + const cusProducts = customer.customer_products; + const entity = customer.entity; + + const cusProduct = productToCusProduct({ + cusProducts, + productId: product_id, + internalEntityId: entity?.internal_id, + version: version ? parseInt(version) : undefined, + cusProductId: customer_product_id, + inStatuses: ACTIVE_STATUSES, + }); + + const product = cusProduct + ? cusProductToProduct({ cusProduct }) + : await ProductService.getFull({ + db, + orgId: org.id, + env, + idOrInternalId: product_id, + version: + version && Number.isInteger(parseInt(version)) + ? parseInt(version) + : undefined, + }); + + const productV2 = mapToProductV2({ product: product!, features }); + + return c.json({ + cusProduct, + product: productV2, + }); + }, +}); diff --git a/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts b/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts new file mode 100644 index 000000000..54c8fc8ef --- /dev/null +++ b/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts @@ -0,0 +1,47 @@ +import type { Customer } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { CusBatchService } from "../CusBatchService"; +import { CusSearchService } from "../CusSearchService"; + +/** + * POST /customers/all/full_customers + * Used by: + * - vite/src/views/onboarding4/hooks/useOnboardingProgress.tsx + * - vite/src/views/customers/hooks/useFullCusSearchQuery.tsx + */ +export const handleGetFullCustomers = createRoute({ + body: z.object({ + search: z.string().optional(), + page_size: z.number().optional().default(50), + page: z.number().optional().default(1), + last_item: z.any().optional(), + filters: z.any().optional(), + }), + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + const { search, page_size, page, last_item, filters } = c.req.valid("json"); + + const { data: customers } = await CusSearchService.search({ + db, + orgId: org.id, + env, + search: search || "", + filters, + lastItem: last_item, + pageNumber: page, + pageSize: page_size, + }); + + const fullCustomers = await CusBatchService.getByInternalIds({ + db, + org, + env, + internalCustomerIds: customers.map( + (customer: Customer) => customer.internal_id, + ), + }); + + return c.json({ fullCustomers }); + }, +}); diff --git a/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts b/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts new file mode 100644 index 000000000..7a1281982 --- /dev/null +++ b/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts @@ -0,0 +1,37 @@ +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { CusSearchService } from "../CusSearchService"; + +/** + * POST /customers/all/search + * Used by: + * - vite/src/hooks/common/useShowDeployButton.tsx + * - vite/src/views/command-bar/CommandBar.tsx + * - vite/src/views/customers/hooks/useCusSearchQuery.tsx + */ +export const handleSearchCustomers = createRoute({ + body: z.object({ + search: z.string().optional(), + page_size: z.number().optional().default(50), + page: z.number().optional().default(1), + last_item: z.any().optional(), + filters: z.any().optional(), + }), + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + const { search, page_size, page, last_item, filters } = c.req.valid("json"); + + const { data: customers, count } = await CusSearchService.search({ + db, + orgId: org.id, + env, + search: search ?? "", + filters, + lastItem: last_item, + pageNumber: page, + pageSize: page_size, + }); + + return c.json({ customers, totalCount: Number(count) }); + }, +}); diff --git a/server/src/internal/dev/cliAuth/cliAuthUtils.ts b/server/src/internal/dev/cliAuth/cliAuthUtils.ts new file mode 100644 index 000000000..3fd8f784e --- /dev/null +++ b/server/src/internal/dev/cliAuth/cliAuthUtils.ts @@ -0,0 +1,48 @@ +import * as crypto from "node:crypto"; + +/** + * Generates a cryptographically secure 6-digit OTP + */ +export const generateOtp = (): string => { + const getRandomInt = (): number => { + if ( + typeof crypto !== "undefined" && + typeof crypto.getRandomValues === "function" + ) { + const array = new Uint32Array(1); + crypto.getRandomValues(array); + return array[0]; + } + + // Node.js (SSR / tests) – use crypto module's webcrypto if available + try { + const { webcrypto } = require("node:crypto"); + if (webcrypto?.getRandomValues) { + const arr = new Uint32Array(1); + webcrypto.getRandomValues(arr); + return arr[0]; + } + } catch (_) { + /* ignore */ + } + + // Fallback (non-cryptographic) + return Math.floor(Math.random() * 0xffffffff); + }; + + // Limit to range [100000, 999999] + const randomSixDigits = (getRandomInt() % 900000) + 100000; + return randomSixDigits.toString(); +}; + +/** + * Generates a random hex key of the specified length + */ +export const generateRandomKey = (lengthInBytes: number = 32): string => { + if (lengthInBytes <= 0) { + throw new Error("Key length must be a positive number."); + } + return crypto.randomBytes(lengthInBytes).toString("hex"); +}; + +export const OTP_TTL = 300; diff --git a/server/src/internal/dev/devRouter.ts b/server/src/internal/dev/devRouter.ts index 40c4e82de..d8526fbd0 100644 --- a/server/src/internal/dev/devRouter.ts +++ b/server/src/internal/dev/devRouter.ts @@ -1,273 +1,19 @@ -import * as crypto from "node:crypto"; -import { AppEnv } from "@autumn/shared"; -import { Router } from "express"; import { Hono } from "hono"; -import type Stripe from "stripe"; -import { - checkKeyValid, - createWebhookEndpoint, -} from "@/external/stripe/stripeOnboardingUtils.js"; -import { withOrgAuth } from "@/middleware/authMiddleware.js"; -import { CacheManager } from "@/utils/cacheUtils/CacheManager.js"; -import { encryptData } from "@/utils/encryptUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { redis } from "../../external/redis/initRedis.js"; import type { HonoEnv } from "../../honoUtils/HonoEnv.js"; -import { OrgService } from "../orgs/OrgService.js"; -import { clearOrgCache } from "../orgs/orgUtils/clearOrgCache.js"; -import { isStripeConnected } from "../orgs/orgUtils.js"; -import { createKey } from "./api-keys/apiKeyUtils.js"; +import { handleCliStripe } from "./handlers/handleCliStripe.js"; +import { handleCreateOtp } from "./handlers/handleCreateOtp.js"; import { handleCreateSecretKey } from "./handlers/handleCreateSecretKey.js"; import { handleDeleteSecretKey } from "./handlers/handleDeleteSecretKey.js"; import { handleGetDevData } from "./handlers/handleGetDevData.js"; +import { handleGetOtp } from "./handlers/handleGetOtp.js"; -export const devRouter: Router = Router(); - -const generateOtp = (): string => { - // Use Web Crypto API if available for cryptographically-secure randomness - const getRandomInt = (): number => { - if ( - typeof crypto !== "undefined" && - typeof crypto.getRandomValues === "function" - ) { - const array = new Uint32Array(1); - crypto.getRandomValues(array); - return array[0]; - } - - // Node.js (SSR / tests) – use crypto module's webcrypto if available - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { webcrypto } = require("crypto"); - if (webcrypto?.getRandomValues) { - const arr = new Uint32Array(1); - webcrypto.getRandomValues(arr); - return arr[0]; - } - } catch (_) { - /* ignore */ - } - - // Fallback (non-cryptographic) - return Math.floor(Math.random() * 0xffffffff); - }; - - // Limit to range [100000, 999999] - const randomSixDigits = (getRandomInt() % 900000) + 100000; - return randomSixDigits.toString(); -}; - -const OTP_TTL = 300; - -export const handleCreateOtp = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "Create OTP", - handler: async () => { - const { orgId } = req; - - // Check if there's already an OTP to use - const maybeCacheKey = `orgOTPExists:${orgId}`; - const maybeCacheData = await CacheManager.getJson(maybeCacheKey); - if (maybeCacheData) { - res.status(200).json({ - otp: maybeCacheData, - }); - return; - } - - // Generate OTP - const otp = generateOtp(); - - const cacheData = { - otp: otp, - orgId: orgId, - }; - - const cacheKey = `otp:${otp}`; - await CacheManager.setJson(cacheKey, cacheData, OTP_TTL); - - const orgCacheKey = `orgOTPExists:${orgId}`; - await CacheManager.setJson(orgCacheKey, otp, OTP_TTL); - - res.status(200).json({ - otp, - }); - }, - }); - -devRouter.post("/otp", withOrgAuth, handleCreateOtp); - -export const generateRandomKey = (lengthInBytes: number = 32): string => { - if (lengthInBytes <= 0) { - throw new Error("Key length must be a positive number."); - } - return crypto.randomBytes(lengthInBytes).toString("hex"); -}; - -export const handleGetOtp = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "Get OTP", - handler: async () => { - const { db, env } = req; - const { otp } = req.params; - const cacheKey = `otp:${otp}`; - const cacheData = await CacheManager.getJson<{ - orgId: string; - stripeFlowAuthKey: string; - }>(cacheKey); - if (!cacheData) { - res.status(404).json({ error: "OTP not found" }); - return; - } - - // Generate API key for the OTP - const sandboxKey = await createKey({ - db, - env: AppEnv.Sandbox, - name: `Autumn Key CLI`, - orgId: cacheData.orgId, - prefix: "am_sk_test", - meta: { - fromCli: true, - generatedAt: new Date().toISOString(), - }, - userId: req.user?.id, - }); - - const prodKey = await createKey({ - db, - env: AppEnv.Live, - name: `Autumn Key CLI`, - orgId: cacheData.orgId, - prefix: "am_sk_live", - meta: { - fromCli: true, - generatedAt: new Date().toISOString(), - }, - userId: req.user?.id, - }); - - const org = await OrgService.get({ - db: req.db, - orgId: cacheData.orgId, - }); - - const stripeConnected = isStripeConnected({ org, env: AppEnv.Sandbox }); - - const responseData = { - ...cacheData, - stripe_connected: stripeConnected, - sandboxKey, - prodKey, - }; - - await CacheManager.invalidate({ - action: "otp", - value: otp, - }); - await CacheManager.invalidate({ - action: "orgOTPExists", - value: cacheData.orgId, - }); - - if (!stripeConnected) { - // we need to generate a key for the CLI to use. - const key = generateRandomKey(); - responseData.stripeFlowAuthKey = key; - const stripeCacheData = { - orgId: cacheData.orgId, - }; - await CacheManager.setJson(key, stripeCacheData, OTP_TTL); - } - - res.status(200).json(responseData); - }, - }); - -devRouter.post("/cli/stripe", async (req: any, res: any) => { - routeHandler({ - req, - res, - action: "Get Stripe Flow Auth Key", - handler: async () => { - const { db, logger } = req; - const key = req.headers["authorization"]; - if (!key) { - res.status(401).json({ message: "Unauthorized" }); - return; - } - - const cacheData = await CacheManager.getJson<{ orgId: string }>(key); - if (!cacheData) { - res.status(404).json({ message: "Key not found" }); - return; - } - - const { orgId } = cacheData; - const { stripeTestKey, stripeLiveKey } = req.body; - - await clearOrgCache({ - db, - orgId, - logger, - }); - - await checkKeyValid(stripeTestKey); - await checkKeyValid(stripeLiveKey); - - let testWebhook: Stripe.WebhookEndpoint; - let liveWebhook: Stripe.WebhookEndpoint; - - try { - testWebhook = await createWebhookEndpoint( - stripeTestKey, - AppEnv.Sandbox, - orgId, - ); - - liveWebhook = await createWebhookEndpoint( - stripeLiveKey, - AppEnv.Live, - orgId, - ); - } catch (error) { - console.log(error); - res.status(500).json({ message: "Error creating stripe webhook" }); - return; - } - - await OrgService.update({ - db, - orgId: orgId, - updates: { - stripe_connected: true, - default_currency: "usd", - stripe_config: { - test_api_key: encryptData(stripeTestKey), - live_api_key: encryptData(stripeLiveKey), - test_webhook_secret: encryptData(testWebhook.secret as string), - live_webhook_secret: encryptData(liveWebhook.secret as string), - // success_url: "https://useautumn.com", - }, - }, - }); - - await redis.del(key); - - res.status(200).json({ - message: "Stripe keys updated", - }); - }, - }); -}); - -devRouter.get("/otp/:otp", handleGetOtp); +// Unauthenticated CLI routes (no session required) +export const cliDevRouter = new Hono(); +cliDevRouter.get("/otp/:otp", ...handleGetOtp); +cliDevRouter.post("/cli/stripe", ...handleCliStripe); export const internalDevRouter = new Hono(); +internalDevRouter.post("/otp", ...handleCreateOtp); internalDevRouter.get("/data", ...handleGetDevData); internalDevRouter.post("/api_key", ...handleCreateSecretKey); internalDevRouter.delete("/api_key/:key_id", ...handleDeleteSecretKey); diff --git a/server/src/internal/dev/handlers/handleCliStripe.ts b/server/src/internal/dev/handlers/handleCliStripe.ts new file mode 100644 index 000000000..bf5d91b50 --- /dev/null +++ b/server/src/internal/dev/handlers/handleCliStripe.ts @@ -0,0 +1,87 @@ +import { AppEnv, RecaseError } from "@autumn/shared"; +import { z } from "zod/v4"; +import { redis } from "@/external/redis/initRedis"; +import { + checkKeyValid, + createWebhookEndpoint, +} from "@/external/stripe/stripeOnboardingUtils"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { OrgService } from "@/internal/orgs/OrgService"; +import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache"; +import { CacheManager } from "@/utils/cacheUtils/CacheManager"; +import { encryptData } from "@/utils/encryptUtils"; + +/** + * POST /dev/cli/stripe + * Connects Stripe keys from CLI authentication flow + */ +export const handleCliStripe = createRoute({ + body: z.object({ + stripeTestKey: z.string(), + stripeLiveKey: z.string(), + }), + handler: async (c) => { + const { db, logger } = c.get("ctx"); + const key = c.req.header("authorization"); + + if (!key) { + throw new RecaseError({ + message: "Unauthorized", + code: "unauthorized", + statusCode: 401, + }); + } + + const cacheData = await CacheManager.getJson<{ orgId: string }>(key); + if (!cacheData) { + throw new RecaseError({ + message: "Key not found", + code: "key_not_found", + statusCode: 404, + }); + } + + const { orgId } = cacheData; + const { stripeTestKey, stripeLiveKey } = c.req.valid("json"); + + await clearOrgCache({ + db, + orgId, + logger, + }); + + await checkKeyValid(stripeTestKey); + await checkKeyValid(stripeLiveKey); + + const testWebhook = await createWebhookEndpoint( + stripeTestKey, + AppEnv.Sandbox, + orgId, + ); + + const liveWebhook = await createWebhookEndpoint( + stripeLiveKey, + AppEnv.Live, + orgId, + ); + + await OrgService.update({ + db, + orgId: orgId, + updates: { + stripe_connected: true, + default_currency: "usd", + stripe_config: { + test_api_key: encryptData(stripeTestKey), + live_api_key: encryptData(stripeLiveKey), + test_webhook_secret: encryptData(testWebhook.secret as string), + live_webhook_secret: encryptData(liveWebhook.secret as string), + }, + }, + }); + + await redis.del(key); + + return c.json({ message: "Stripe keys updated" }); + }, +}); diff --git a/server/src/internal/dev/handlers/handleCreateOtp.ts b/server/src/internal/dev/handlers/handleCreateOtp.ts new file mode 100644 index 000000000..3bcd2468f --- /dev/null +++ b/server/src/internal/dev/handlers/handleCreateOtp.ts @@ -0,0 +1,38 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { CacheManager } from "@/utils/cacheUtils/CacheManager"; +import { generateOtp, OTP_TTL } from "../cliAuth/cliAuthUtils"; + +/** + * POST /dev/otp (from the dashboard) + * Creates an OTP for CLI authentication + */ +export const handleCreateOtp = createRoute({ + handler: async (c) => { + const { org } = c.get("ctx"); + + // Check if there's already an OTP to use + const maybeCacheKey = `orgOTPExists:${org.id}`; + const maybeCacheData = await CacheManager.getJson(maybeCacheKey); + if (maybeCacheData) { + return c.json({ otp: maybeCacheData }); + } + + // Generate OTP + const otp = generateOtp(); + + const cacheData = { + otp: otp, + orgId: org.id, + }; + + const cacheKey = `otp:${otp}`; + await CacheManager.setJson(cacheKey, cacheData, OTP_TTL); + + const orgCacheKey = `orgOTPExists:${org.id}`; + await CacheManager.setJson(orgCacheKey, otp, OTP_TTL); + + console.log("OTP created", otp); + + return c.json({ otp }); + }, +}); diff --git a/server/src/internal/dev/handlers/handleGetOtp.ts b/server/src/internal/dev/handlers/handleGetOtp.ts new file mode 100644 index 000000000..c9d56f089 --- /dev/null +++ b/server/src/internal/dev/handlers/handleGetOtp.ts @@ -0,0 +1,100 @@ +import { AppEnv, RecaseError } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { OrgService } from "@/internal/orgs/OrgService"; +import { isStripeConnected } from "@/internal/orgs/orgUtils"; +import { CacheManager } from "@/utils/cacheUtils/CacheManager"; +import { createKey } from "../api-keys/apiKeyUtils"; +import { generateRandomKey, OTP_TTL } from "../cliAuth/cliAuthUtils"; + +/** + * GET /dev/otp/:otp + * Validates OTP and returns API keys for CLI + */ +export const handleGetOtp = createRoute({ + handler: async (c) => { + const { db, user } = c.get("ctx"); + const { otp } = c.req.param(); + + const cacheKey = `otp:${otp}`; + const cacheData = await CacheManager.getJson<{ + orgId: string; + stripeFlowAuthKey: string; + }>(cacheKey); + + if (!cacheData) { + throw new RecaseError({ + message: "OTP not found", + code: "otp_not_found", + statusCode: 404, + }); + } + + // Generate API key for the OTP + const sandboxKey = await createKey({ + db, + env: AppEnv.Sandbox, + name: "Autumn Key CLI", + orgId: cacheData.orgId, + prefix: "am_sk_test", + meta: { + fromCli: true, + generatedAt: new Date().toISOString(), + }, + userId: user?.id, + }); + + const prodKey = await createKey({ + db, + env: AppEnv.Live, + name: "Autumn Key CLI", + orgId: cacheData.orgId, + prefix: "am_sk_live", + meta: { + fromCli: true, + generatedAt: new Date().toISOString(), + }, + userId: user?.id, + }); + + const org = await OrgService.get({ + db, + orgId: cacheData.orgId, + }); + + const stripeConnected = isStripeConnected({ org, env: AppEnv.Sandbox }); + + const responseData: { + orgId: string; + stripeFlowAuthKey?: string; + stripe_connected: boolean; + sandboxKey: string; + prodKey: string; + } = { + ...cacheData, + stripe_connected: stripeConnected, + sandboxKey, + prodKey, + }; + + await CacheManager.invalidate({ + action: "otp", + value: otp, + }); + await CacheManager.invalidate({ + action: "orgOTPExists", + value: cacheData.orgId, + }); + + if (!stripeConnected) { + // Generate a key for the CLI to use for Stripe flow + const key = generateRandomKey(); + responseData.stripeFlowAuthKey = key; + const stripeCacheData = { + orgId: cacheData.orgId, + }; + await CacheManager.setJson(key, stripeCacheData, OTP_TTL); + } + + return c.json(responseData); + }, +}); diff --git a/server/src/internal/mainRouter.ts b/server/src/internal/mainRouter.ts index 4b63049fd..ec18c9e85 100644 --- a/server/src/internal/mainRouter.ts +++ b/server/src/internal/mainRouter.ts @@ -1,6 +1,5 @@ import "dotenv/config"; -import { Autumn } from "autumn-js"; import { autumnHandler } from "autumn-js/express"; import { Router } from "express"; import rateLimit from "express-rate-limit"; @@ -8,29 +7,14 @@ import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { withOrgAuth } from "../middleware/authMiddleware.js"; import { analyticsRouter } from "./analytics/internalAnalyticsRouter.js"; import { trmnlRouter } from "./api/trmnl/trmnlRouter.js"; -import { cusRouter } from "./customers/internalCusRouter.js"; -import { devRouter } from "./dev/devRouter.js"; import { InvoiceService } from "./invoices/InvoiceService.js"; -import { onboardingRouter } from "./orgs/onboarding/onboardingRouter.js"; -import { expressProductRouter } from "./products/internalProductRouter.js"; import { viewsRouter } from "./saved-views/savedViewsRouter.js"; const mainRouter: Router = Router(); -// mainRouter.get("", async (req: any, res) => { -// res.status(200).json({ message: "Hello World" }); -// }); - -// mainRouter.use("/users", withAuth, userRouter); -mainRouter.use("/onboarding", withOrgAuth, onboardingRouter); - -mainRouter.use("/products", withOrgAuth, expressProductRouter); -mainRouter.use("/dev", devRouter); -mainRouter.use("/customers", withOrgAuth, cusRouter); mainRouter.use("/query", withOrgAuth, analyticsRouter); mainRouter.use("/saved_views", withOrgAuth, viewsRouter); - mainRouter.use("/trmnl", trmnlRouter); const limiter = rateLimit({ diff --git a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts index 79a6eb7c9..c46d8e698 100644 --- a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts +++ b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts @@ -1,4 +1,4 @@ -import { generateId, InternalError, MetadataType } from "@autumn/shared"; +import { InternalError, MetadataType } from "@autumn/shared"; import { addDays } from "date-fns"; import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli"; @@ -9,6 +9,7 @@ import type { BillingPlan, DeferredAutumnBillingPlanData, } from "@/internal/billing/v2/types/billingPlan"; +import { generateId } from "@/utils/genUtils"; import { MetadataService } from "../MetadataService"; /** diff --git a/server/src/internal/orgs/onboarding/onboardingRouter.ts b/server/src/internal/orgs/onboarding/onboardingRouter.ts deleted file mode 100644 index 0c376c957..000000000 --- a/server/src/internal/orgs/onboarding/onboardingRouter.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { AppEnv, chatResults } from "@autumn/shared"; -import { eq } from "drizzle-orm"; -import { Router } from "express"; -import { FeatureService } from "@/internal/features/FeatureService.js"; -import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; -import { ProductService } from "@/internal/products/ProductService.js"; -import { PriceService } from "@/internal/products/prices/PriceService.js"; -import RecaseError from "@/utils/errorUtils.js"; -import type { - ExtendedRequest, - ExtendedResponse, -} from "@/utils/models/Request.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { parseChatResultFeatures } from "./parseChatFeatures.js"; -import { parseChatProducts } from "./parseChatProducts.js"; - -export const onboardingRouter: Router = Router(); - -onboardingRouter.post("", async (req: Request, res: any) => - routeHandler({ - req, - res, - action: "onboarding", - handler: async (req: ExtendedRequest, res: ExtendedResponse) => { - const { db, logger, org } = req; - const { token } = req.body; - - if (!token) { - throw new RecaseError({ - message: "No token provided", - code: "no_token_provided", - statusCode: 400, - }); - } - - const chatResult = await db.query.chatResults.findFirst({ - where: eq(chatResults.id, token), - }); - - if (!chatResult) { - throw new RecaseError({ - message: `Chat result from token ${token} not found`, - code: "chat_result_not_found", - statusCode: 404, - }); - } - - const curProducts = await ProductService.listFull({ - db, - orgId: org.id, - env: AppEnv.Sandbox, - }); - - const curFeatures = await FeatureService.list({ - db, - orgId: org.id, - env: AppEnv.Sandbox, - }); - - const newProducts = chatResult.data.products.filter((product) => { - return !curProducts.some((p) => p.id === product.id); - }); - - const newFeatures = chatResult.data.features.filter((feature) => { - return !curFeatures.some((f) => f.id === feature.id); - }); - - if (newFeatures.length > 0 || newProducts.length > 0) { - const backendFeatures = parseChatResultFeatures({ - features: newFeatures, - orgId: org.id, - }); - - const { products, prices, ents } = await parseChatProducts({ - db, - logger, - orgId: org.id, - features: [...curFeatures, ...backendFeatures], - chatProducts: newProducts, - }); - - await Promise.all([ - FeatureService.insert({ - db, - data: backendFeatures, - logger, - }), - (async () => { - for (const product of products) { - await ProductService.insert({ db, product }); - } - })(), - ]); - - await EntitlementService.insert({ - db, - data: ents, - }); - - await PriceService.insert({ - db, - data: prices, - }); - } - - res.status(200).json({ - org_id: org.id, - feature_ids: chatResult.data.features.map((f) => f.id), - product_ids: chatResult.data.products.map((p) => p.id), - }); - }, - }), -); diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index 8b7bbd48b..f3f38fcd4 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -269,7 +269,6 @@ export class ProductService { prices: { where: eq(prices.is_custom, false) }, free_trials: { where: eq(freeTrials.is_custom, false) }, }, - // orderBy: [desc(products.internal_id)], })) as FullProduct[]; parseFreeTrials({ products: data }); diff --git a/server/src/internal/products/free-trials/freeTrialUtils.ts b/server/src/internal/products/free-trials/freeTrialUtils.ts index efc29fdeb..1e351d212 100644 --- a/server/src/internal/products/free-trials/freeTrialUtils.ts +++ b/server/src/internal/products/free-trials/freeTrialUtils.ts @@ -3,7 +3,6 @@ import { ErrCode, type FreeTrial, FreeTrialDuration, - initFreeTrial, type Price, } from "@autumn/shared"; import type { DrizzleCli } from "@server/db/initDrizzle.js"; @@ -13,6 +12,7 @@ import { ProductService } from "@server/internal/products/ProductService.js"; import { isOneOff } from "@server/internal/products/productUtils.js"; import RecaseError from "@server/utils/errorUtils.js"; import { addDays, addMinutes, addMonths, addYears } from "date-fns"; +import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial"; export const validateOneOffTrial = async ({ prices, diff --git a/shared/utils/productUtils/freeTrialUtils/initFreeTrial.ts b/server/src/internal/products/free-trials/initFreeTrial.ts similarity index 76% rename from shared/utils/productUtils/freeTrialUtils/initFreeTrial.ts rename to server/src/internal/products/free-trials/initFreeTrial.ts index 68c88c31b..280fba6c3 100644 --- a/shared/utils/productUtils/freeTrialUtils/initFreeTrial.ts +++ b/server/src/internal/products/free-trials/initFreeTrial.ts @@ -2,8 +2,8 @@ import { type CreateFreeTrial, CreateFreeTrialSchema, type FreeTrial, -} from "@models/productModels/freeTrialModels/freeTrialModels"; -import { generateId } from "@utils/utils"; +} from "@autumn/shared"; +import { generateId } from "@/utils/genUtils"; export const initFreeTrial = ({ freeTrialParams, @@ -24,5 +24,3 @@ export const initFreeTrial = ({ is_custom: isCustom, }; }; - -// card_required: freeTrial.card_required ?? true, diff --git a/server/src/internal/products/internalHandlers/handleGetFeatures.ts b/server/src/internal/products/internalHandlers/handleGetFeatures.ts new file mode 100644 index 000000000..ccccc9288 --- /dev/null +++ b/server/src/internal/products/internalHandlers/handleGetFeatures.ts @@ -0,0 +1,12 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler"; + +/** + * GET /products/features + * Used by: vite/src/hooks/queries/useFeaturesQuery.tsx + */ +export const handleGetFeatures = createRoute({ + handler: async (c) => { + const { features } = c.get("ctx"); + return c.json({ features }); + }, +}); diff --git a/server/src/internal/products/internalHandlers/handleGetMigrations.ts b/server/src/internal/products/internalHandlers/handleGetMigrations.ts new file mode 100644 index 000000000..5079ab800 --- /dev/null +++ b/server/src/internal/products/internalHandlers/handleGetMigrations.ts @@ -0,0 +1,20 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { MigrationService } from "@/internal/migrations/MigrationService"; + +/** + * GET /products/migrations + * Used by: vite/src/views/products/product/hooks/queries/useMigrationsQuery.tsx.tsx + */ +export const handleGetMigrations = createRoute({ + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + + const migrations = await MigrationService.getExistingJobs({ + db, + orgId: org.id, + env, + }); + + return c.json({ migrations }); + }, +}); diff --git a/server/src/internal/products/internalHandlers/handleGetProductCounts.ts b/server/src/internal/products/internalHandlers/handleGetProductCounts.ts new file mode 100644 index 000000000..c3616a46e --- /dev/null +++ b/server/src/internal/products/internalHandlers/handleGetProductCounts.ts @@ -0,0 +1,47 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService"; +import { ProductService } from "@/internal/products/ProductService"; + +/** + * GET /products/product_counts + * Used by: vite/src/hooks/queries/useProductsQuery.tsx + */ +export const handleGetProductCounts = createRoute({ + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + + const products = await ProductService.listFull({ + db, + orgId: org.id, + env: env, + }); + + const counts = await Promise.all( + products.map(async (product) => { + return CusProdReadService.getCountsForAllVersions({ + db, + productId: product.id, + orgId: org.id, + env: env, + }); + }), + ); + + const result: { + [key: string]: { + active: number; + canceled: number; + custom: number; + trialing: number; + all: number; + }; + } = {}; + for (let i = 0; i < products.length; i++) { + if (!result[products[i].id]) { + result[products[i].id] = counts[i]; + } + } + + return c.json(result); + }, +}); diff --git a/server/src/internal/products/internalHandlers/handleGetProducts.ts b/server/src/internal/products/internalHandlers/handleGetProducts.ts new file mode 100644 index 000000000..4acf3c517 --- /dev/null +++ b/server/src/internal/products/internalHandlers/handleGetProducts.ts @@ -0,0 +1,39 @@ +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"; + +/** + * GET /products/products + * Used by: + * - vite/src/hooks/queries/useProductsQuery.tsx + * - vite/src/views/onboarding4/hooks/useOnboardingProgress.tsx + */ +export const handleGetProducts = createRoute({ + handler: async (c) => { + const { db, org, env, features } = c.get("ctx"); + let products = await ProductService.listFull({ + db, + orgId: org.id, + env: env, + }); + + if (process.env.NODE_ENV === "development") { + products = products.slice(0, 10); + } + + sortFullProducts({ products }); + + const groupToDefaults = getGroupToDefaults({ + defaultProds: products, + }); + + return c.json({ + products: products.map((p) => + mapToProductV2({ product: p, features: features }), + ), + groupToDefaults, + }); + }, +}); diff --git a/server/src/internal/products/internalHandlers/handleGetRewards.ts b/server/src/internal/products/internalHandlers/handleGetRewards.ts new file mode 100644 index 000000000..7c091c4e9 --- /dev/null +++ b/server/src/internal/products/internalHandlers/handleGetRewards.ts @@ -0,0 +1,22 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { RewardProgramService } from "@/internal/rewards/RewardProgramService"; +import { RewardService } from "@/internal/rewards/RewardService"; + +/** + * GET /products/rewards + * Used by: vite/src/hooks/queries/useRewardsQuery.tsx + */ +export const handleGetRewards = createRoute({ + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + + const rewards = await RewardService.list({ db, orgId: org.id, env }); + const rewardPrograms = await RewardProgramService.list({ + db, + orgId: org.id, + env, + }); + + return c.json({ rewards, rewardPrograms }); + }, +}); diff --git a/server/src/internal/products/internalHandlers/handleHasEntityFeatureId.ts b/server/src/internal/products/internalHandlers/handleHasEntityFeatureId.ts new file mode 100644 index 000000000..3ecd964ca --- /dev/null +++ b/server/src/internal/products/internalHandlers/handleHasEntityFeatureId.ts @@ -0,0 +1,20 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; + +/** + * GET /products/has_entity_feature_id + * Used by: vite/src/views/products/plan/hooks/useHasEntityFeatureId.ts + */ +export const handleHasEntityFeatureId = createRoute({ + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + + const hasEntityFeatureId = await EntitlementService.hasEntityFeatureId({ + db, + orgId: org.id, + env, + }); + + return c.json({ hasEntityFeatureId }); + }, +}); diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 832bb036d..45861ff2d 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -1,340 +1,12 @@ -import { type FeatureOptions, UsageModel } from "@autumn/shared"; import { Router } from "express"; import { handleFrontendReqError } from "@/utils/errorUtils.js"; -import { CusProdReadService } from "../customers/cusProducts/CusProdReadService.js"; -import { FeatureService } from "../features/FeatureService.js"; -import { MigrationService } from "../migrations/MigrationService.js"; -import { OrgService } from "../orgs/OrgService.js"; -import { createOrgResponse } from "../orgs/orgUtils.js"; -import { RewardProgramService } from "../rewards/RewardProgramService.js"; -import { RewardService } from "../rewards/RewardService.js"; import { EntitlementService } from "./entitlements/EntitlementService.js"; import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js"; -import { ProductService } from "./ProductService.js"; -import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js"; -import { sortFullProducts } from "./productUtils/sortProductUtils.js"; -import { - getGroupToDefaults, - getLatestProducts, - getProductVersionCounts, -} from "./productUtils.js"; -import { mapToProductV2 } from "./productV2Utils.js"; export const expressProductRouter: Router = Router({ mergeParams: true }); -// Get list of products -expressProductRouter.get("/products", async (req: any, res) => { - try { - const { db } = req; - const products = await ProductService.listFull({ - db, - orgId: req.orgId, - env: req.env, - }); - - sortFullProducts({ products }); - - const groupToDefaults = getGroupToDefaults({ - defaultProds: products, - }); - - res.status(200).json({ - products: products.map((p) => - mapToProductV2({ product: p, features: req.features }), - ), - groupToDefaults, - }); - } catch (error) { - console.error("Failed to get products", error); - res.status(500).send(error); - } -}); - -// Get counts for all products -expressProductRouter.get("/product_counts", async (req: any, res) => { - try { - const { db } = req; - const products = await ProductService.listFull({ - db, - orgId: req.orgId, - env: req.env, - }); - - const counts = await Promise.all( - products.map(async (product) => { - return CusProdReadService.getCountsForAllVersions({ - db, - productId: product.id, - orgId: req.orgId, - env: req.env, - }); - }), - ); - - const result: { [key: string]: any } = {}; - for (let i = 0; i < products.length; i++) { - if (!result[products[i].id]) { - result[products[i].id] = counts[i]; - } - } - - res.status(200).send(result); - } catch (error) { - console.error("Failed to get products", error); - res.status(500).send(error); - } -}); - -// Get list of features -expressProductRouter.get("/features", async (req: any, res) => { - try { - res.status(200).json({ features: req.features }); - } catch (error) { - console.error("Failed to get features", error); - res.status(500).send(error); - } -}); - -// Get list of rewards -expressProductRouter.get("/rewards", async (req: any, res) => { - try { - const { db, orgId, env } = req; - const rewards = await RewardService.list({ db, orgId, env }); - const rewardPrograms = await RewardProgramService.list({ - db, - orgId, - env, - }); - res.status(200).send({ rewards, rewardPrograms }); - } catch (error) { - handleFrontendReqError({ - error, - req, - res, - action: "Get rewards", - }); - } -}); - -// Get list of migrations -expressProductRouter.get("/migrations", async (req: any, res) => { - try { - const { db, orgId, env } = req; - const migrations = await MigrationService.getExistingJobs({ - db, - orgId, - env, - }); - res.status(200).send({ migrations }); - } catch (error) { - handleFrontendReqError({ - error, - req, - res, - action: "Get migrations", - }); - } -}); - -expressProductRouter.get("/data", async (req: any, res) => { - try { - const { db } = req; - - const allVersions = req.query.all_versions === "true"; - - const [products, features, org, coupons, rewardPrograms, defaultProds] = - await Promise.all([ - ProductService.listFull({ - db, - orgId: req.orgId, - env: req.env, - archived: false, - returnAll: allVersions, - }), - FeatureService.getFromReq(req), - OrgService.getFromReq(req), - RewardService.list({ db, orgId: req.orgId, env: req.env }), - RewardProgramService.list({ - db, - orgId: req.orgId, - env: req.env, - }), - ProductService.listDefault({ - db, - orgId: req.orgId, - env: req.env, - }), - ]); - - sortFullProducts({ - products: getLatestProducts(products), - }); - - const groupToDefaultProd = getGroupToDefaults({ - defaultProds, - }); - - res.status(200).json({ - products: products.map((product) => { - return mapToProductV2({ product, features }); - }), - versionCounts: getProductVersionCounts(products), - features, - org: createOrgResponse({ org, env: req.env }), - rewards: coupons, - rewardPrograms, - groupToDefaults: groupToDefaultProd, - }); - } catch (error) { - console.error("Failed to get products", error); - res.status(500).send(error); - } -}); - -expressProductRouter.post("/data", async (req: any, res) => { - try { - const { db } = req; - const { showArchived } = req.body; - - const [products, defaultProds, features, org, coupons, rewardPrograms] = - await Promise.all([ - ProductService.listFull({ - db, - orgId: req.orgId, - env: req.env, - // returnAll: true, - archived: showArchived, - }), - ProductService.listDefault({ - db, - orgId: req.orgId, - env: req.env, - }), - FeatureService.getFromReq(req), - OrgService.getFromReq(req), - RewardService.list({ db, orgId: req.orgId, env: req.env }), - RewardProgramService.list({ - db, - orgId: req.orgId, - env: req.env, - }), - ]); - - // Group to default product - const groupToDefaultProd = getGroupToDefaults({ - defaultProds, - }); - - res.status(200).json({ - products: sortFullProducts({ products }).map((product) => { - return mapToProductV2({ product, features }); - }), - groupToDefaults: groupToDefaultProd, - versionCounts: getProductVersionCounts(products), - features, - org: createOrgResponse({ org, env: req.env }), - rewards: coupons, - rewardPrograms, - }); - } catch (error) { - console.error("Failed to get products", error); - res.status(500).send(error); - } -}); - -expressProductRouter.get("/counts", async (req: any, res) => { - try { - const { db } = req; - const products = await ProductService.listFull({ - db, - orgId: req.orgId, - env: req.env, - // returnAll: true, - }); - - const latestVersion = req.query.latest_version === "true"; - - const counts = await Promise.all( - products.map(async (product) => { - if (latestVersion) { - return CusProdReadService.getCounts({ - db, - internalProductId: product.internal_id, - }); - } - - return CusProdReadService.getCountsForAllVersions({ - db, - productId: product.id, - orgId: req.orgId, - env: req.env, - }); - }), - ); - - const result: { [key: string]: any } = {}; - for (let i = 0; i < products.length; i++) { - if (!result[products[i].id]) { - result[products[i].id] = counts[i]; - } - } - - res.status(200).send(result); - } catch (error) { - console.error("Failed to get product counts", error); - res.status(500).send(error); - } -}); - -expressProductRouter.post("/product_options", async (req: any, res: any) => { - try { - const { items } = req.body; - - const featureToOptions: { [key: string]: FeatureOptions } = {}; - - for (const item of items) { - if (isFeaturePriceItem(item) && item.usage_model === UsageModel.Prepaid) { - featureToOptions[item.feature_id] = { - feature_id: item.feature_id, - quantity: 0, - }; - } - } - - res.status(200).send({ options: Object.values(featureToOptions) }); - } catch (error) { - handleFrontendReqError({ - error, - req, - res, - action: "Get product options", - }); - } -}); - expressProductRouter.get("/:productId/info", handleGetProductDeleteInfo); -expressProductRouter.get("/rewards", async (req: any, res: any) => { - try { - const { db, orgId, env } = req; - - const rewards = await RewardService.list({ - db, - orgId, - env, - }); - - res.status(200).send({ rewards }); - } catch (error) { - handleFrontendReqError({ - error, - req, - res, - action: "Get rewards", - }); - } -}); - expressProductRouter.get( "/has_entity_feature_id", async (req: any, res: any) => { @@ -361,13 +33,25 @@ expressProductRouter.get( import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleGetProducts } from "@/internal/products/internalHandlers/handleGetProducts.js"; import { handleCopyEnvironment } from "./handlers/handleCopyEnvironment/handleCopyEnvironment.js"; +import { handleGetFeatures } from "./internalHandlers/handleGetFeatures.js"; +import { handleGetMigrations } from "./internalHandlers/handleGetMigrations.js"; import { handleGetProductCount } from "./internalHandlers/handleGetProductCount.js"; +import { handleGetProductCounts } from "./internalHandlers/handleGetProductCounts.js"; import { handleGetProductInternal } from "./internalHandlers/handleGetProductInternal.js"; +import { handleGetRewards } from "./internalHandlers/handleGetRewards.js"; // Hono router for internal/dashboard product routes export const internalProductRouter = new Hono(); +internalProductRouter.get("/products", ...handleGetProducts); +internalProductRouter.get("/product_counts", ...handleGetProductCounts); +internalProductRouter.get("/features", ...handleGetFeatures); +internalProductRouter.get("/rewards", ...handleGetRewards); +internalProductRouter.get("/migrations", ...handleGetMigrations); + +// SINGLE PRODUCT ENDPOINTS internalProductRouter.get("/:productId/count", ...handleGetProductCount); internalProductRouter.get("/:productId/data", ...handleGetProductInternal); internalProductRouter.post("/copy_to_production", ...handleCopyEnvironment); diff --git a/server/src/routers/cliRouter.ts b/server/src/routers/cliRouter.ts new file mode 100644 index 000000000..51d2929f5 --- /dev/null +++ b/server/src/routers/cliRouter.ts @@ -0,0 +1,12 @@ +import { Hono } from "hono"; +import { baseMiddleware } from "@/honoMiddlewares/baseMiddleware"; +import type { HonoEnv } from "../honoUtils/HonoEnv"; +import { cliDevRouter } from "../internal/dev/devRouter"; + +/** + * Doesn't require authentication + */ +export const cliRouter = new Hono(); +cliRouter.use("*", baseMiddleware); + +cliRouter.route("/dev", cliDevRouter); diff --git a/server/src/utils/logging/initLogger.ts b/server/src/utils/logging/initLogger.ts index 52e1a8e59..fc78c9094 100644 --- a/server/src/utils/logging/initLogger.ts +++ b/server/src/utils/logging/initLogger.ts @@ -118,7 +118,7 @@ export const initLogger = () => { const streams: pino.StreamEntry[] = []; const isDev = process.env.NODE_ENV === "development"; - const isTest = process.env.NODE_ENV === "test"; + const isTest = process.env.NODE_ENV === "test2"; // Enable dev logging for development OR test environments if (isDev || isTest) { diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-basic.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-basic.test.ts index c6beed743..7704a5c55 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-basic.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-basic.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; +import { type ApiCustomerV3, applyProration } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { @@ -746,8 +746,10 @@ test.concurrent(`${chalk.yellowBright("p2p: change to unlimited")}`, async () => // 7.1 Mid-cycle (15 days) price increase test.concurrent(`${chalk.yellowBright("p2p: mid-cycle price increase")}`, async () => { + const oldPrice = 20; + const newPrice = 30; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const priceItem = items.monthlyPrice({ price: 20 }); + const priceItem = items.monthlyPrice({ price: oldPrice }); const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); const { customerId, autumnV1, ctx, testClockId } = await initScenario({ @@ -771,14 +773,29 @@ test.concurrent(`${chalk.yellowBright("p2p: mid-cycle price increase")}`, async ); // Advance 15 days (mid-cycle) - await advanceTestClock({ + const advancedTo = await advanceTestClock({ stripeCli: ctx.stripeCli, testClockId: testClockId!, numberOfDays: 15, }); + // Use floored seconds to match Stripe's frozen_time calculation + const frozenTimeMs = Math.floor(advancedTo / 1000) * 1000; + + // Get billing period from customer's subscription + const customerBefore = + await autumnV1.customers.get(customerId); + const subscription = customerBefore.products?.[0]; + if (!subscription?.current_period_start || !subscription?.current_period_end) + throw new Error("Missing billing period on subscription"); + + const billingPeriod = { + start: subscription.current_period_start, + end: subscription.current_period_end, + }; + // Increase price from $20 to $30 - const newPriceItem = items.monthlyPrice({ price: 30 }); + const newPriceItem = items.monthlyPrice({ price: newPrice }); const updateParams = { customer_id: customerId, @@ -788,8 +805,20 @@ test.concurrent(`${chalk.yellowBright("p2p: mid-cycle price increase")}`, async const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - // Should charge ~$5 (prorated $10 difference for ~15 remaining days) - expect(preview.total).toBe(5); + // Calculate exact proration: credit old price + charge new price + const proratedOldPrice = applyProration({ + now: frozenTimeMs, + billingPeriod, + amount: oldPrice, + }); + const proratedNewPrice = applyProration({ + now: frozenTimeMs, + billingPeriod, + amount: newPrice, + }); + const expectedAmount = proratedNewPrice - proratedOldPrice; + + expect(preview.total).toBeCloseTo(expectedAmount, 0); await autumnV1.subscriptions.update(updateParams); @@ -820,8 +849,10 @@ test.concurrent(`${chalk.yellowBright("p2p: mid-cycle price increase")}`, async // 7.2 Mid-cycle (15 days) price decrease test.concurrent(`${chalk.yellowBright("p2p: mid-cycle price decrease")}`, async () => { + const oldPrice = 30; + const newPrice = 20; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const priceItem = items.monthlyPrice({ price: 30 }); + const priceItem = items.monthlyPrice({ price: oldPrice }); const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); const { customerId, autumnV1, ctx, testClockId } = await initScenario({ @@ -845,14 +876,29 @@ test.concurrent(`${chalk.yellowBright("p2p: mid-cycle price decrease")}`, async ); // Advance 15 days (mid-cycle) - await advanceTestClock({ + const advancedTo = await advanceTestClock({ stripeCli: ctx.stripeCli, testClockId: testClockId!, numberOfDays: 15, }); + // Use floored seconds to match Stripe's frozen_time calculation + const frozenTimeMs = Math.floor(advancedTo / 1000) * 1000; + + // Get billing period from customer's subscription + const customerBefore = + await autumnV1.customers.get(customerId); + const subscription = customerBefore.products?.[0]; + if (!subscription?.current_period_start || !subscription?.current_period_end) + throw new Error("Missing billing period on subscription"); + + const billingPeriod = { + start: subscription.current_period_start, + end: subscription.current_period_end, + }; + // Decrease price from $30 to $20 - const newPriceItem = items.monthlyPrice({ price: 20 }); + const newPriceItem = items.monthlyPrice({ price: newPrice }); const updateParams = { customer_id: customerId, @@ -862,8 +908,20 @@ test.concurrent(`${chalk.yellowBright("p2p: mid-cycle price decrease")}`, async const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - // Should credit ~$5 (prorated $10 difference for ~15 remaining days) - expect(preview.total).toBe(-5); + // Calculate exact proration: credit old price + charge new price + const proratedOldPrice = applyProration({ + now: frozenTimeMs, + billingPeriod, + amount: oldPrice, + }); + const proratedNewPrice = applyProration({ + now: frozenTimeMs, + billingPeriod, + amount: newPrice, + }); + const expectedAmount = proratedNewPrice - proratedOldPrice; + + expect(preview.total).toBeCloseTo(expectedAmount, 0); await autumnV1.subscriptions.update(updateParams); diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts index 5fcd6190f..7d653870d 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts @@ -782,92 +782,6 @@ test.concurrent(`${chalk.yellowBright("prepaid: change price, billing units, and }); }); -// ═══════════════════════════════════════════════════════════════════════════════ -// EDGE CASES -// ═══════════════════════════════════════════════════════════════════════════════ - -// No item changes (just options with same quantity) -test.concurrent(`${chalk.yellowBright("prepaid: no item changes")}`, async () => { - const billingUnits = 100; - const pricePerPack = 10; - - const prepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits, - price: pricePerPack, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [prepaidItem, priceItem], - }); - - const packs = 3; - const quantity = packs * billingUnits; - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "prepaid-no-item-change", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - const messagesUsed = 50; - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Same item, same quantity - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [prepaidItem, priceItem], - options: [{ feature_id: TestFeature.Messages, quantity }], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // No change - expect(preview.total).toBe(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: quantity, // 0 + 300 = 300 - balance: quantity - messagesUsed, - usage: messagesUsed, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - // Zero usage, change item config test.concurrent(`${chalk.yellowBright("prepaid: zero usage, change item config")}`, async () => { const oldBillingUnits = 100; diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-while-canceling.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-while-canceling.test.ts index d3b840d19..ebf625bad 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-while-canceling.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-while-canceling.test.ts @@ -215,8 +215,6 @@ test.concurrent(`${chalk.yellowBright("update while downgrading")}`, async () => items: [updatedConsumableItem, newPriceItem], }); - console.log("Preview total (update canceling product):", preview.total); - await autumnV1.subscriptions.update({ customer_id: customerId, product_id: premium.id, @@ -227,8 +225,6 @@ test.concurrent(`${chalk.yellowBright("update while downgrading")}`, async () => const customerAfterUpdate = await autumnV1.customers.get(customerId); - console.log("Products after update:", customerAfterUpdate.products); - // Premium product should remain canceling (with updated items) await expectProductCanceling({ customer: customerAfterUpdate, @@ -267,8 +263,6 @@ test.concurrent(`${chalk.yellowBright("update while downgrading")}`, async () => const customerAfterAdvance = await autumnV1.customers.get(customerId); - console.log("Products after advance:", customerAfterAdvance.products); - // Pro should now be active await expectProductActive({ customer: customerAfterAdvance, @@ -370,11 +364,6 @@ test.concurrent(`${chalk.yellowBright("update while canceling: preserves usage") items: [updatedMessagesItem, items.monthlyPrice()], }); - console.log( - "Preview total (update while canceling with usage):", - preview.total, - ); - await autumnV1.subscriptions.update({ customer_id: customerId, product_id: pro.id, diff --git a/server/tests/unit/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts b/server/tests/unit/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts index 0e9b8b5a3..028d9ca49 100644 --- a/server/tests/unit/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts +++ b/server/tests/unit/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts @@ -689,10 +689,8 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { currentCustomerProduct: cusProduct, }); - // quantity: 0 is falsy, so paramsToFeatureOptions returns undefined - // Falls back to current quantity expect(result).toHaveLength(1); - expect(result[0].quantity).toBe(100); + expect(result[0].quantity).toBe(0); }); test("handles feature matched by internal_feature_id in current options", () => { diff --git a/server/tests/unit/billing/stripe/stripeSubscriptionTestHelpers.ts b/server/tests/unit/billing/stripe/stripeSubscriptionTestHelpers.ts index c8299a369..2da89eb24 100644 --- a/server/tests/unit/billing/stripe/stripeSubscriptionTestHelpers.ts +++ b/server/tests/unit/billing/stripe/stripeSubscriptionTestHelpers.ts @@ -13,10 +13,9 @@ */ import { expect } from "bun:test"; +import { FeatureUsageType } from "@autumn/shared"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; -import { - prices, -} from "@tests/utils/fixtures/db/prices"; +import { prices } from "@tests/utils/fixtures/db/prices"; import { products } from "@tests/utils/fixtures/db/products"; // ============ TIME CONSTANTS ============ @@ -116,6 +115,7 @@ export const createProductWithAllPriceTypes = ({ }); // Allocated: has usage (allowance - balance = usage) + // Must set usage_type: Continuous for isAllocatedCustomerEntitlement to return true const allocatedAllowance = 10; const allocatedEntitlement = customerEntitlements.create({ entitlementId: `ent_${productId}_seats`, @@ -125,6 +125,7 @@ export const createProductWithAllPriceTypes = ({ allowance: allocatedAllowance, balance: allocatedAllowance - allocatedUsage, // Usage = allocatedUsage customerProductId, + featureConfig: { usage_type: FeatureUsageType.Continuous }, }); // Feature options for prepaid quantity diff --git a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update.spec.ts b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update.spec.ts index 54465ce1c..9dacf4b38 100644 --- a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update.spec.ts +++ b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update.spec.ts @@ -13,6 +13,7 @@ import { CusProductStatus } from "@autumn/shared"; import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import { prices } from "@tests/utils/fixtures/db/prices"; +import { stripeSubscriptions } from "@tests/utils/fixtures/stripe/subscriptions"; import chalk from "chalk"; import { buildStripeSubscriptionItemsUpdate } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate"; import { @@ -22,7 +23,6 @@ import { expectSubscriptionItemsUpdate, getExpectedNewProductItems, } from "../stripeSubscriptionTestHelpers"; -import { stripeSubscriptions } from "@tests/utils/fixtures/stripe/subscriptions"; // ============ TESTS ============ diff --git a/server/tests/utils/fixtures/db/customerEntitlements.ts b/server/tests/utils/fixtures/db/customerEntitlements.ts index b922f6acf..036c22197 100644 --- a/server/tests/utils/fixtures/db/customerEntitlements.ts +++ b/server/tests/utils/fixtures/db/customerEntitlements.ts @@ -19,6 +19,7 @@ const create = ({ balance, customerProductId, featureType = FeatureType.Metered, + featureConfig = {}, interval = null, intervalCount = 1, usageAllowed = true, @@ -35,6 +36,7 @@ const create = ({ balance: number; customerProductId?: string; featureType?: FeatureType; + featureConfig?: Record; interval?: EntInterval | null; intervalCount?: number; usageAllowed?: boolean; @@ -66,6 +68,7 @@ const create = ({ featureName, allowance, featureType, + featureConfig, interval, intervalCount, entityFeatureId, diff --git a/server/tests/utils/fixtures/db/entitlements.ts b/server/tests/utils/fixtures/db/entitlements.ts index 90a4bbd19..6d995b816 100644 --- a/server/tests/utils/fixtures/db/entitlements.ts +++ b/server/tests/utils/fixtures/db/entitlements.ts @@ -11,6 +11,7 @@ const create = ({ featureName, allowance, featureType = FeatureType.Metered, + featureConfig = {}, interval = null, intervalCount = 1, entityFeatureId = null, @@ -21,6 +22,7 @@ const create = ({ featureName: string; allowance: number; featureType?: FeatureType; + featureConfig?: Record; interval?: EntInterval | null; intervalCount?: number; entityFeatureId?: string | null; @@ -44,6 +46,7 @@ const create = ({ internalId: internalFeatureId, name: featureName, type: featureType, + config: featureConfig, }), }); diff --git a/server/tests/utils/fixtures/db/features.ts b/server/tests/utils/fixtures/db/features.ts index 71477aab2..807712d42 100644 --- a/server/tests/utils/fixtures/db/features.ts +++ b/server/tests/utils/fixtures/db/features.ts @@ -8,11 +8,13 @@ const create = ({ internalId, name, type = FeatureType.Metered, + config = {}, }: { id: string; internalId?: string; name: string; type?: FeatureType; + config?: Record; }) => ({ internal_id: internalId ?? `internal_${id}`, org_id: "org_test", @@ -21,7 +23,7 @@ const create = ({ id, name, type, - config: {}, + config, display: null, archived: false, event_names: [], diff --git a/shared/package.json b/shared/package.json index cde588b66..baef66ec8 100644 --- a/shared/package.json +++ b/shared/package.json @@ -22,7 +22,6 @@ "db:studio": "cross-env NODE_OPTIONS=\"--import tsx\" bunx drizzle-kit studio --config drizzle.config.ts" }, "dependencies": { - "@owpz/ksuid": "^25.7.20", "@date-fns/utc": "catalog:", "date-fns": "^4.1.0", "decimal.js": "^10.5.0", diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts index 412e9e397..9f6d23acd 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts @@ -1,13 +1,11 @@ +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { BillingType } from "@models/productModels/priceModels/priceEnums"; +import { getCusEntBalance } from "@utils/cusEntUtils/balanceUtils"; +import { cusEntToCusPrice } from "@utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice"; +import { entToOptions } from "@utils/productUtils/convertProductUtils"; +import { getBillingType } from "@utils/productUtils/priceUtils"; +import { nullish } from "@utils/utils"; import { Decimal } from "decimal.js"; -import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js"; -import { - cusEntToCusPrice, - entToOptions, -} from "../../productUtils/convertUtils.js"; -import { getBillingType } from "../../productUtils/priceUtils.js"; -import { nullish } from "../../utils.js"; -import { getCusEntBalance } from "../balanceUtils.js"; export const cusEntToPurchasedBalance = ({ cusEnt, diff --git a/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts b/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts index dc99ad5ee..c4932e6ca 100644 --- a/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts +++ b/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts @@ -90,28 +90,6 @@ export const sortCusEntsForDeduction = ({ return 1; } - // // Handle overage vs prepaid ordering: - // // - An entitlement is "in overage mode" if usage_allowed=true AND balance <= 0 - // // - An entitlement has "prepaid balance" if balance > 0 (regardless of usage_allowed) - // // - For deductions: entitlements with prepaid balance go FIRST, overage mode goes LAST - // // - For refunds: overage mode goes FIRST (recover overage before prepaid) - // // Note: When both have prepaid balance, interval sorting (below) determines order - // const aBalance = a.balance ?? 0; - // const bBalance = b.balance ?? 0; - // const aInOverageMode = a.usage_allowed && aBalance <= 0; - // const bInOverageMode = b.usage_allowed && bBalance <= 0; - - // if (aInOverageMode !== bInOverageMode) { - // if (aInOverageMode && !bInOverageMode) { - // // a is in overage mode, b has prepaid balance - // return isRefund ? -1 : 1; - // } - // if (!aInOverageMode && bInOverageMode) { - // // a has prepaid balance, b is in overage mode - // return isRefund ? 1 : -1; - // } - // } - // If one has a next_reset_at, it should go first const nextResetFirst = reverseOrder ? 1 : -1; @@ -162,6 +140,19 @@ export const sortCusEntsForDeduction = ({ return -1; } + // 2.5. Sort by usage_allowed (prepaid goes before pay-per-use) + // Prepaid (usage_allowed=false) should be deducted first since it can't go negative + if (a.usage_allowed !== b.usage_allowed) { + // If a is prepaid (false) and b is pay-per-use (true), a goes first + if (!a.usage_allowed && b.usage_allowed) { + return -1; + } + // If a is pay-per-use (true) and b is prepaid (false), b goes first + if (a.usage_allowed && !b.usage_allowed) { + return 1; + } + } + // 4. Sort by created_at return a.created_at - b.created_at; }); diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 47926a47b..bb0e35a73 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -47,7 +47,6 @@ export * from "./productUtils/classifyProduct/isProductPaidAndRecurring.js"; // Product utils export * from "./productUtils/convertProductUtils.js"; export * from "./productUtils/entUtils/index.js"; -export * from "./productUtils/freeTrialUtils/initFreeTrial.js"; export * from "./productUtils/freeTrialUtils.js"; export * from "./productUtils/isProductUpgrade.js"; export * from "./productUtils/priceUtils/index.js"; diff --git a/shared/utils/utils.ts b/shared/utils/utils.ts index 8b497756e..ba3904d6e 100644 --- a/shared/utils/utils.ts +++ b/shared/utils/utils.ts @@ -1,11 +1,5 @@ -import { KSUID } from "@owpz/ksuid"; import { Decimal } from "decimal.js"; -export const generateId = (prefix?: string): string => { - const id = KSUID.random().toString(); - return prefix ? `${prefix}_${id}` : id; -}; - export const nullish = ( value: T | null | undefined, ): value is null | undefined => { diff --git a/vite/src/services/products/ProductService.tsx b/vite/src/services/products/ProductService.tsx index 586b109d1..54ea8835b 100644 --- a/vite/src/services/products/ProductService.tsx +++ b/vite/src/services/products/ProductService.tsx @@ -42,12 +42,6 @@ export class ProductService { await axiosInstance.post(`/products/${productId}/prices`, data); } - static async getRequiredOptions(axiosInstance: AxiosInstance, data: any) { - return await axiosInstance.post(`/products/product_options`, { - ...data, - }); - } - static async copyProduct( axiosInstance: AxiosInstance, productId: string, diff --git a/vite/src/views/cli/Otp.tsx b/vite/src/views/cli/Otp.tsx index e851dce9f..13932bd1b 100644 --- a/vite/src/views/cli/Otp.tsx +++ b/vite/src/views/cli/Otp.tsx @@ -1,11 +1,11 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { InputOTP, InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; -import { toast } from "sonner"; import { DevService } from "@/services/DevService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; @@ -20,7 +20,9 @@ export const Otp = () => { useEffect(() => { const fetchOtp = async () => { + console.log("Fetching OTP"); const { otp } = await DevService.createOTP(axiosInstance); + console.log("OTP fetched", otp); setTheOtp(otp); }; fetchOtp(); diff --git a/vite/vite.config.ts b/vite/vite.config.ts index f07d31ba7..4d857b400 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -3,7 +3,6 @@ import { sentryVitePlugin } from "@sentry/vite-plugin"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; -import { nodePolyfills } from "vite-plugin-node-polyfills"; import tsconfigPaths from "vite-tsconfig-paths"; // https://vite.dev/config/ @@ -15,11 +14,6 @@ export default defineConfig({ react(), tailwindcss(), // Automatically reads paths from tsconfig.json tsconfigPaths(), - nodePolyfills({ - // Only polyfill Buffer for @owpz/ksuid - include: ["buffer"], - globals: { Buffer: true }, - }), sentryVitePlugin({ org: process.env.VITE_SENTRY_ORG, project: process.env.VITE_SENTRY_PROJECT,