migrated some routes to hono

This commit is contained in:
John Yeo
2026-01-15 16:23:20 +00:00
parent 105e4dad40
commit 6580ec3dac
50 changed files with 850 additions and 1141 deletions

View File

@@ -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=="],

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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<HonoEnv>();
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);

View File

@@ -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,
});
},
});

View File

@@ -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 });
},
});

View File

@@ -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,
});
},
});

View File

@@ -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 });
},
});

View File

@@ -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) });
},
});

View File

@@ -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;

View File

@@ -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<HonoEnv>();
cliDevRouter.get("/otp/:otp", ...handleGetOtp);
cliDevRouter.post("/cli/stripe", ...handleCliStripe);
export const internalDevRouter = new Hono<HonoEnv>();
internalDevRouter.post("/otp", ...handleCreateOtp);
internalDevRouter.get("/data", ...handleGetDevData);
internalDevRouter.post("/api_key", ...handleCreateSecretKey);
internalDevRouter.delete("/api_key/:key_id", ...handleDeleteSecretKey);

View File

@@ -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" });
},
});

View File

@@ -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 });
},
});

View File

@@ -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);
},
});

View File

@@ -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({

View File

@@ -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";
/**

View File

@@ -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),
});
},
}),
);

View File

@@ -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 });

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 });
},
});

View File

@@ -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 });
},
});

View File

@@ -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);
},
});

View File

@@ -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,
});
},
});

View File

@@ -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 });
},
});

View File

@@ -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 });
},
});

View File

@@ -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<HonoEnv>();
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);

View File

@@ -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<HonoEnv>();
cliRouter.use("*", baseMiddleware);
cliRouter.route("/dev", cliDevRouter);

View File

@@ -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) {

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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);

View File

@@ -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<ApiCustomerV3>(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;

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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,

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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 ============

View File

@@ -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<string, unknown>;
interval?: EntInterval | null;
intervalCount?: number;
usageAllowed?: boolean;
@@ -66,6 +68,7 @@ const create = ({
featureName,
allowance,
featureType,
featureConfig,
interval,
intervalCount,
entityFeatureId,

View File

@@ -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<string, unknown>;
interval?: EntInterval | null;
intervalCount?: number;
entityFeatureId?: string | null;
@@ -44,6 +46,7 @@ const create = ({
internalId: internalFeatureId,
name: featureName,
type: featureType,
config: featureConfig,
}),
});

View File

@@ -8,11 +8,13 @@ const create = ({
internalId,
name,
type = FeatureType.Metered,
config = {},
}: {
id: string;
internalId?: string;
name: string;
type?: FeatureType;
config?: Record<string, unknown>;
}) => ({
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: [],

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;
});

View File

@@ -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";

View File

@@ -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 = <T>(
value: T | null | undefined,
): value is null | undefined => {

View File

@@ -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,

View File

@@ -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();

View File

@@ -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,