migrated some routes to hono
This commit is contained in:
5
bun.lock
5
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=="],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
48
server/src/internal/dev/cliAuth/cliAuthUtils.ts
Normal file
48
server/src/internal/dev/cliAuth/cliAuthUtils.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
87
server/src/internal/dev/handlers/handleCliStripe.ts
Normal file
87
server/src/internal/dev/handlers/handleCliStripe.ts
Normal 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" });
|
||||
},
|
||||
});
|
||||
38
server/src/internal/dev/handlers/handleCreateOtp.ts
Normal file
38
server/src/internal/dev/handlers/handleCreateOtp.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
100
server/src/internal/dev/handlers/handleGetOtp.ts
Normal file
100
server/src/internal/dev/handlers/handleGetOtp.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
12
server/src/routers/cliRouter.ts
Normal file
12
server/src/routers/cliRouter.ts
Normal 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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user