diff --git a/server/src/errors/errCodes.ts b/server/src/errors/errCodes.ts index e04b72354..cdd1d2512 100644 --- a/server/src/errors/errCodes.ts +++ b/server/src/errors/errCodes.ts @@ -1,81 +1,75 @@ -export const ErrCode = { - // General - InvalidRequest: "invalid_request", - InvalidId: "invalid_id", +// export const ErrCode = { +// // General +// InvalidRequest: "invalid_request", +// InvalidId: "invalid_id", - // Org - CreateClerkOrgFailed: "create_clerk_org_failed", - AssignUserToOrgFailed: "assign_user_to_org_failed", +// // Org +// CreateClerkOrgFailed: "create_clerk_org_failed", +// AssignUserToOrgFailed: "assign_user_to_org_failed", - // Feature - FeatureNotFound: "feature_not_found", - InvalidFeature: "invalid_feature", - DuplicateFeatureId: "duplicate_feature_id", - UpdateFeatureFailed: "update_feature_failed", +// // Feature +// FeatureNotFound: "feature_not_found", +// InvalidFeature: "invalid_feature", +// DuplicateFeatureId: "duplicate_feature_id", +// UpdateFeatureFailed: "update_feature_failed", - // Internal - InternalError: "internal_error", - DuplicateCustomerId: "duplicate_customer_id", - StripeKeyNotFound: "stripe_key_not_found", +// // Internal +// InternalError: "internal_error", +// DuplicateCustomerId: "duplicate_customer_id", +// StripeKeyNotFound: "stripe_key_not_found", - // Stripe - StripeKeyInvalid: "stripe_key_invalid", - StripeConfigNotFound: "stripe_config_not_found", - StripeDeleteCustomerFailed: "stripe_delete_customer_failed", - StripeCreateCustomerFailed: "stripe_create_customer_failed", - StripeCreateProductFailed: "stripe_create_product_failed", - StripeCancelSubscriptionFailed: "stripe_cancel_subscription_failed", +// // Stripe +// StripeKeyInvalid: "stripe_key_invalid", +// StripeConfigNotFound: "stripe_config_not_found", +// StripeDeleteCustomerFailed: "stripe_delete_customer_failed", +// StripeCreateCustomerFailed: "stripe_create_customer_failed", +// StripeCreateProductFailed: "stripe_create_product_failed", +// StripeCancelSubscriptionFailed: "stripe_cancel_subscription_failed", - // Price - PriceNotFound: "price_not_found", - CreatePriceFailed: "create_price_failed", - InvalidPrice: "invalid_price", - InvalidPriceId: "invalid_price_id", - InvalidPriceOptions: "invalid_price_options", - InvalidPriceConfig: "invalid_price_config", +// // Price +// PriceNotFound: "price_not_found", +// CreatePriceFailed: "create_price_failed", +// InvalidPrice: "invalid_price", +// InvalidPriceId: "invalid_price_id", +// InvalidPriceOptions: "invalid_price_options", +// InvalidPriceConfig: "invalid_price_config", - // Customer - InvalidCustomer: "invalid_customer", - CreateCustomerFailed: "create_customer_failed", - CustomerNotFound: "customer_not_found", - CustomerAlreadyHasProduct: "customer_already_has_product", - CustomerHasNoPaymentMethod: "customer_has_no_payment_method", - CustomerHasNoBaseProduct: "customer_has_no_base_product", - AttachProductToCustomerFailed: "attach_product_to_customer_failed", - MultipleProductsFound: "multiple_products_found", - MultipleCustomersFound: "multiple_customers_found", - GetCusWithProductsFailed: "get_cus_with_products_failed", +// // Customer +// InvalidCustomer: "invalid_customer", +// CreateCustomerFailed: "create_customer_failed", +// CustomerNotFound: "customer_not_found", +// CustomerAlreadyHasProduct: "customer_already_has_product", +// CustomerHasNoPaymentMethod: "customer_has_no_payment_method", +// CustomerHasNoBaseProduct: "customer_has_no_base_product", +// AttachProductToCustomerFailed: "attach_product_to_customer_failed", +// MultipleProductsFound: "multiple_products_found", +// MultipleCustomersFound: "multiple_customers_found", +// GetCusWithProductsFailed: "get_cus_with_products_failed", - // Product - InvalidProduct: "invalid_product", - ProductAlreadyExists: "product_already_exists", - ProductNotFound: "product_not_found", - ProductHasCustomers: "product_has_customers", - ProductHasNoPrices: "product_has_no_prices", - ProductHasDifferentRecurringIntervals: - "product_has_different_recurring_intervals", - CreateStripeProductFailed: "create_stripe_product_failed", - DeleteStripeProductFailed: "delete_stripe_product_failed", - CreateStripeSubscriptionFailed: "create_stripe_subscription_failed", - UpdateCusProductFailed: "update_customer_product_failed", - DefaultProductNotAllowedPrice: "default_product_not_allowed_price", - InvalidOptions: "invalid_options", +// // Product - // Entitlements - InvalidEntitlement: "invalid_entitlement", - CreateEntitlementFailed: "create_entitlement_failed", +// CreateStripeProductFailed: "create_stripe_product_failed", +// DeleteStripeProductFailed: "delete_stripe_product_failed", +// CreateStripeSubscriptionFailed: "create_stripe_subscription_failed", +// UpdateCusProductFailed: "update_customer_product_failed", +// DefaultProductNotAllowedPrice: "default_product_not_allowed_price", +// InvalidOptions: "invalid_options", - // Invoice - CreateInvoiceFailed: "create_invoice_failed", - PayInvoiceFailed: "pay_invoice_failed", +// // Entitlements +// InvalidEntitlement: "invalid_entitlement", +// CreateEntitlementFailed: "create_entitlement_failed", - // Payment errors - CardDeclinedError: "card_declined_error", +// // Invoice +// CreateInvoiceFailed: "create_invoice_failed", +// PayInvoiceFailed: "pay_invoice_failed", - // Entity - EntityNotFound: "entity_not_found", +// // Payment errors +// CardDeclinedError: "card_declined_error", - // Analytics - NoEventsFound: "no_events_found", - ClickHouseNotEnabled: "clickhouse_not_enabled", -}; +// // Entity +// EntityNotFound: "entity_not_found", + +// // Analytics +// NoEventsFound: "no_events_found", +// ClickHouseNotEnabled: "clickhouse_not_enabled", +// }; diff --git a/server/src/external/clerkUtils.ts b/server/src/external/clerkUtils.ts deleted file mode 100644 index 09f603cb6..000000000 --- a/server/src/external/clerkUtils.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { ErrCode } from "@/errors/errCodes.js"; -import { decryptData } from "@/utils/encryptUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { AppEnv } from "@autumn/shared"; -import { clerkClient, createClerkClient } from "@clerk/express"; - -export const createClerkCli = () => { - return createClerkClient({ - secretKey: process.env.CLERK_SECRET_KEY, - }); -}; - -export const createClerkOrg = async (name: string, slug: string) => { - const clerkCli = createClerkCli(); - - try { - let org = await clerkCli.organizations.createOrganization({ - name, - slug, - }); - return org; - } catch (error: any) { - if (error.errors && error.errors.length > 0) { - const errMessage = error.errors[0].message; - throw new RecaseError({ - code: ErrCode.CreateClerkOrgFailed, - message: errMessage, - }); - } else { - throw new RecaseError({ - code: ErrCode.InternalError, - message: "Error creating organization", - }); - } - } -}; - -export const assignUserToOrg = async (userId: string, orgId: string) => { - const clerkCli = createClerkCli(); - try { - await clerkCli.organizations.createOrganizationMembership({ - userId, - role: "org:admin", - organizationId: orgId, - }); - } catch (error: any) { - if (error.errors && error.errors.length > 0) { - const errMessage = error.errors[0].message; - throw new RecaseError({ - code: ErrCode.AssignUserToOrgFailed, - message: errMessage, - }); - } else { - throw new RecaseError({ - code: ErrCode.InternalError, - message: "Error assigning user to organization", - }); - } - } -}; - -export const getOrgById = async (orgId: string) => { - const orgRes = await clerkClient.organizations.getOrganization({ - organizationId: orgId, - }); - return orgRes; -}; - -export const getStripeKey = async (orgId: string, env: AppEnv) => { - const orgRes = await getOrgById(orgId); - let meta: any = orgRes.privateMetadata; - - // let key = - // env == AppEnv.Sandbox - // ? meta.stripe?.test_api_key - // : meta.stripe?.live_api_key; - - // TODO: Change this to conditional - let key = meta.stripe?.test_api_key; - - if (!key) { - throw new RecaseError({ - code: ErrCode.StripeKeyNotFound, - message: "Stripe key not found", - }); - } - - return decryptData(key); -}; - -export const createOrgAndAssignUser = async ( - name: string, - slug: string, - userId: string, -) => { - try { - const orgRes = await clerkClient.organizations.createOrganization({ - name, - slug, - }); - - await clerkClient.organizations.createOrganizationMembership({ - userId, - role: "org:admin", - organizationId: orgRes.id, - }); - - return orgRes; - } catch (error) { - console.log("Clerk error:", error); - } -}; diff --git a/server/src/external/redis/redisUtils.ts b/server/src/external/redis/redisUtils.ts index 577b42ba1..d8e210c77 100644 --- a/server/src/external/redis/redisUtils.ts +++ b/server/src/external/redis/redisUtils.ts @@ -1,4 +1,4 @@ -import { ErrCode } from "@/errors/errCodes.js"; +import { ErrCode } from "@autumn/shared"; import { QueueManager } from "@/queue/QueueManager.js"; import RecaseError from "@/utils/errorUtils.js"; @@ -26,7 +26,7 @@ export const handleAttachRaceCondition = async ({ // Create lock with 5 second timeout await redisConn.set(lockKey, "1", "PX", 5000, "NX"); - let originalJson = res.json; + const originalJson = res.json; res.json = async function (body: any) { try { await clearLock({ lockKey, logger: req.logtail }); @@ -80,7 +80,7 @@ export const handleCustomerRaceCondition = async ({ // Create lock with 5 second timeout await redisConn.set(lockKey, "1", "PX", 5000, "NX"); - let originalJson = res.json; + const originalJson = res.json; res.json = async function (body: any) { try { await clearLock({ lockKey, logger }); diff --git a/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts b/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts index 425807bb4..67eb0ed44 100644 --- a/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts +++ b/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts @@ -1,28 +1,29 @@ +import { + APIVersion, + BillingType, + type EntitlementWithFeature, + ErrCode, + type FeatureOptions, + type FixedPriceConfig, + type FullProduct, + InternalError, + type Organization, + type Price, + type ProductOptions, + type UsagePriceConfig, +} from "@autumn/shared"; import { getBillingType, priceIsOneOffAndTiered, } from "@/internal/products/prices/priceUtils.js"; import RecaseError from "@/utils/errorUtils.js"; - -import { - BillingType, - ErrCode, - FeatureOptions, - FixedPriceConfig, - FullProduct, - Organization, - ProductOptions, - UsagePriceConfig, -} from "@autumn/shared"; -import { EntitlementWithFeature, Price, APIVersion } from "@autumn/shared"; - +import { notNullish } from "@/utils/genUtils.js"; +import { billingIntervalToStripe } from "../stripePriceUtils.js"; +import { priceToInArrearProrated } from "./priceToArrearProrated.js"; import { priceToOneOffAndTiered, priceToUsageInAdvance, } from "./priceToUsageInAdvance.js"; -import { priceToInArrearProrated } from "./priceToArrearProrated.js"; -import { billingIntervalToStripe } from "../stripePriceUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; export const getEmptyPriceItem = ({ price, @@ -80,20 +81,18 @@ export const priceToStripeItem = ({ : 1; if (!stripeProductId) { - throw new RecaseError({ - code: ErrCode.ProductNotFound, - message: "Product not created in Stripe", - statusCode: 400, + throw new InternalError({ + message: `product ${product.id} has no stripe product id`, }); } - let lineItemMeta = null; + const lineItemMeta = null; let lineItem = null; // 1. FIXED PRICE if ( - billingType == BillingType.FixedCycle || - billingType == BillingType.OneOff + billingType === BillingType.FixedCycle || + billingType === BillingType.OneOff ) { const config = price.config as FixedPriceConfig; @@ -105,7 +104,7 @@ export const priceToStripeItem = ({ // 2. PREPAID, TIERED, ONE OFF else if ( - billingType == BillingType.UsageInAdvance && + billingType === BillingType.UsageInAdvance && priceIsOneOffAndTiered(price, relatedEnt) ) { lineItem = priceToOneOffAndTiered({ @@ -118,7 +117,7 @@ export const priceToStripeItem = ({ } // 3. PREPAID - else if (billingType == BillingType.UsageInAdvance) { + else if (billingType === BillingType.UsageInAdvance) { lineItem = priceToUsageInAdvance({ price, options, @@ -128,7 +127,7 @@ export const priceToStripeItem = ({ } // 4. USAGE IN ARREAR - else if (billingType == BillingType.UsageInArrear) { + else if (billingType === BillingType.UsageInArrear) { const config = price.config as UsagePriceConfig; const priceId = config.stripe_price_id; @@ -165,7 +164,7 @@ export const priceToStripeItem = ({ } // 5. USAGE ARREAR PRORATED - else if (billingType == BillingType.InArrearProrated) { + else if (billingType === BillingType.InArrearProrated) { lineItem = priceToInArrearProrated({ price, isCheckout, diff --git a/server/src/external/stripe/stripeProductUtils.ts b/server/src/external/stripe/stripeProductUtils.ts index 3fe4f7f1f..8b3ae9617 100644 --- a/server/src/external/stripe/stripeProductUtils.ts +++ b/server/src/external/stripe/stripeProductUtils.ts @@ -1,8 +1,12 @@ -import { AppEnv, Organization, Product } from "@autumn/shared"; -import { createStripeCli } from "./utils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { ErrCode } from "@/errors/errCodes.js"; +import { + AppEnv, + ErrCode, + type Organization, + type Product, +} from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; +import RecaseError from "@/utils/errorUtils.js"; +import { createStripeCli } from "./utils.js"; export const createStripeProduct = async ( org: Organization, @@ -66,7 +70,7 @@ export const deactivateStripeMeters = async ({ }) => { const stripeCli = createStripeCli({ org, env }); - let allStripeMeters = []; + const allStripeMeters = []; let hasMore = true; let startingAfter; @@ -116,7 +120,7 @@ export const deleteAllStripeProducts = async ({ return; } - let firstProduct = stripeProducts.data[0]; + const firstProduct = stripeProducts.data[0]; if (firstProduct.livemode) { throw new RecaseError({ message: "Cannot delete livemode products", @@ -125,9 +129,9 @@ export const deleteAllStripeProducts = async ({ }); } - let batchSize = 50; + const batchSize = 50; for (let i = 0; i < stripeProducts.data.length; i += batchSize) { - let batch = stripeProducts.data.slice(i, i + batchSize); + const batch = stripeProducts.data.slice(i, i + batchSize); await Promise.all( batch.map(async (p) => { try { diff --git a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts index 253e5b8a8..1ee64188d 100644 --- a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts +++ b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts @@ -5,10 +5,10 @@ import { BillingInterval, type EntitlementWithFeature, type Entity, - ErrCode, type FeatureOptions, type FullCusProduct, type FullProduct, + InternalError, isUsagePrice, type Organization, type Price, @@ -31,7 +31,6 @@ import { getPriceEntitlement, getProductForPrice, } from "@/internal/products/prices/priceUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import type { ItemSet } from "@/utils/models/ItemSet.js"; import { priceToStripeItem } from "../priceToStripeItem/priceToStripeItem.js"; @@ -152,10 +151,8 @@ export const getStripeSubItems = async ({ }, }, ); - throw new RecaseError({ - code: ErrCode.ProductNotFound, + throw new InternalError({ message: `Price internal product ID: ${price.internal_product_id} not found in products`, - statusCode: 400, }); } @@ -277,10 +274,8 @@ export const getStripeSubItems2 = async ({ }, }, ); - throw new RecaseError({ - code: ErrCode.ProductNotFound, + throw new InternalError({ message: `Price internal product ID: ${price.internal_product_id} not found in products`, - statusCode: 400, }); } diff --git a/server/src/external/stripe/utils.ts b/server/src/external/stripe/utils.ts index a1cea0c62..4c735fad8 100644 --- a/server/src/external/stripe/utils.ts +++ b/server/src/external/stripe/utils.ts @@ -1,17 +1,15 @@ -import { ErrCode } from "@/errors/errCodes.js"; +import { + AppEnv, + BillingInterval, + ErrCode, + type Feature, + Infinite, + type Organization, + type UsagePriceConfig, +} from "@autumn/shared"; +import Stripe from "stripe"; import { decryptData } from "@/utils/encryptUtils.js"; import RecaseError from "@/utils/errorUtils.js"; -import { - BillingInterval, - Feature, - FullProduct, - Infinite, - Organization, - UsagePriceConfig, -} from "@autumn/shared"; - -import { AppEnv } from "@autumn/shared"; -import Stripe from "stripe"; export const createStripeCli = ({ org, @@ -24,7 +22,7 @@ export const createStripeCli = ({ // apiVersion?: string; legacyVersion?: boolean; }) => { - let encrypted = + const encrypted = env == AppEnv.Sandbox ? org.stripe_config?.test_api_key : org.stripe_config?.live_api_key; @@ -37,7 +35,7 @@ export const createStripeCli = ({ }); } - let decrypted = decryptData(encrypted); + const decrypted = decryptData(encrypted); return new Stripe(decrypted, { apiVersion: legacyVersion ? ("2025-02-24.acacia" as any) @@ -83,7 +81,7 @@ export const calculateMetered1Price = ({ }; export const subToAutumnInterval = (sub: Stripe.Subscription) => { - let recuringItem = sub.items.data.find((i) => i.price.recurring != null); + const recuringItem = sub.items.data.find((i) => i.price.recurring != null); if (!recuringItem) { return { interval: BillingInterval.OneOff, diff --git a/server/src/honoMiddlewares/errorMiddleware.ts b/server/src/honoMiddlewares/errorMiddleware.ts new file mode 100644 index 000000000..c1d076cf9 --- /dev/null +++ b/server/src/honoMiddlewares/errorMiddleware.ts @@ -0,0 +1,208 @@ +import { AutumnError, ErrCode } from "@autumn/shared"; +import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import Stripe from "stripe"; +import { ZodError } from "zod/v4"; +import type { HonoEnv } from "@/initHono.js"; +import RecaseError, { formatZodError } from "@/utils/errorUtils.js"; + +/** + * Handle special error cases that should use warn instead of error logging + * Returns a response if the error matches a special case, null otherwise + */ +const handleSpecialErrorCases = ( + err: Error, + c: Context, + ctx: any, + logger: any, +) => { + const url = c.req.url; + + // Special case 1: EntityNotFound - use warn instead of error + if (err instanceof RecaseError && err.code === ErrCode.EntityNotFound) { + logger.warn(`${err.message}, org: ${ctx.org?.slug || "unknown"}`); + return c.json( + { + message: err.message, + code: err.code, + env: ctx.env, + }, + 404, + ); + } + + // Special case 2: Stripe exchange router invalid API key + if ( + err instanceof Stripe.errors.StripeError && + url.includes("/exchange") && + err.message.includes("Invalid API Key provided") + ) { + logger.warn("Exchange router, invalid API Key provided"); + return c.json( + { + message: err.message, + code: ErrCode.InvalidRequest, + env: ctx.env, + }, + 400, + ); + } + + // Special case 3: Billing portal config error + if ( + err instanceof Stripe.errors.StripeError && + url.includes("/billing_portal") && + err.message.includes("Provide a configuration or create your default") + ) { + logger.warn(`Billing portal config error, org: ${ctx.org?.slug}`); + return c.json( + { + message: err.message, + code: ErrCode.InvalidRequest, + env: ctx.env, + }, + 404, + ); + } + + // Special case 4: Billing portal return_url error + if ( + err instanceof Stripe.errors.StripeError && + url.includes("/billing_portal") && + err.message.includes("Invalid URL: An explicit scheme (such as https)") + ) { + logger.warn(`Billing portal return_url error, org: ${ctx.org?.slug}`); + return c.json( + { + message: err.message, + code: ErrCode.InvalidRequest, + env: ctx.env, + }, + 400, + ); + } + + // Special case 5: Zod error on /attach - convert to RecaseError + if (err instanceof ZodError && url.includes("/attach")) { + const formattedError = formatZodError(err); + logger.warn( + `ATTACH ZOD ERROR (${ctx.org?.slug || "unknown"}): ${formattedError}`, + ); + return c.json( + { + message: formattedError, + code: ErrCode.InvalidInputs, + env: ctx.env, + }, + 400, + ); + } + + // No special case matched + return null; +}; + +/** + * Hono error handler middleware + * Handles different error types and responds appropriately with proper logging + */ +export const errorMiddleware = (err: Error, c: Context) => { + const ctx = c.get("ctx"); + const logger = ctx?.logger; + + // If no context/logger available, fallback to console + if (!logger) { + console.error("Error occurred before context was set:", err); + return c.json( + { + message: "Internal server error", + code: ErrCode.InternalError, + }, + 500, + ); + } + + // Check for special error cases first + const specialCaseResponse = handleSpecialErrorCases(err, c, ctx, logger); + if (specialCaseResponse) return specialCaseResponse; + + // 1. Handle RecaseError (our custom errors) + if (err instanceof RecaseError || err instanceof AutumnError) { + logger.warn( + `RECASE WARNING (${ctx.org?.slug || "unknown"}): ${err.message} [${err.code}]`, + { + error: err.data ?? err, + }, + ); + + return c.json( + { + message: err.message, + code: err.code, + env: ctx.env, + }, + err.statusCode as ContentfulStatusCode, + ); + } + + // 2. Handle Stripe errors + if (err instanceof Stripe.errors.StripeError) { + logger.error( + `STRIPE ERROR (${ctx.org?.slug || "unknown"}): ${err.message}`, + { + error: { + type: err.type, + code: err.code, + statusCode: err.statusCode, + }, + }, + ); + + return c.json( + { + message: `(Stripe Error) ${err.message}`, + code: ErrCode.StripeError, + env: ctx.env, + }, + 400, + ); + } + + // 3. Handle Zod validation errors + if (err instanceof ZodError) { + const formattedError = formatZodError(err); + + logger.error( + `ZOD ERROR (${ctx.org?.slug || "unknown"}): ${formattedError}`, + ); + + return c.json( + { + message: formattedError, + code: ErrCode.InvalidInputs, + env: ctx.env, + }, + 400, + ); + } + + // 4. Handle unknown errors + logger.error( + `UNKNOWN ERROR (${ctx.org?.slug || "unknown"}): ${err.message}`, + { + error: { + stack: err.stack, + message: err.message, + }, + }, + ); + + return c.json( + { + message: err.message || "Unknown error", + code: ErrCode.InternalError, + env: ctx.env, + }, + 500, + ); +}; diff --git a/server/src/index.ts b/server/src/index.ts index ef726f947..59da596ac 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -57,6 +57,7 @@ const init = async () => { allowedHeaders: [ "app_env", "x-api-version", + "x-client-type", "Authorization", "Content-Type", "Accept", @@ -91,7 +92,7 @@ const init = async () => { const reqContext = { id: req.id, - env: req.headers["app_env"] || undefined, + env: req.headers.app_env || undefined, method: req.method, url: req.originalUrl, timestamp: req.timestamp, @@ -145,10 +146,6 @@ const init = async () => { app.use("/webhooks", webhooksRouter); - // Smart Hono redirect - checks if route exists in Hono, forwards if match - // Otherwise continues to Express flow completely untouched - app.use(redirectToHono()); - app.use(express.json()); app.use(async (req: any, res: any, next: any) => { req.logtail.info(`${req.method} ${req.originalUrl}`, { diff --git a/server/src/initHono.ts b/server/src/initHono.ts index 27453e958..2f1b06a73 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -2,9 +2,17 @@ import type { AppEnv, AuthType, Feature, Organization } from "@autumn/shared"; import type { ClickHouseClient } from "@clickhouse/client"; import { getRequestListener } from "@hono/node-server"; import { Hono } from "hono"; +import { cors } from "hono/cors"; import type { DrizzleCli } from "./db/initDrizzle.js"; import type { Logger } from "./external/logtail/logtailUtils.js"; +import { apiVersionMiddleware } from "./honoMiddlewares/apiVersionMiddleware.js"; +import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js"; +import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js"; +import { orgConfigMiddleware } from "./honoMiddlewares/orgConfigMiddleware.js"; +import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js"; +import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js"; import { handleCreateProduct } from "./internal/products/honoProductRouter.js"; +import { auth } from "./utils/auth.js"; type RequestContext = { // Variables @@ -61,47 +69,45 @@ const ALLOWED_HEADERS = [ export const createHonoApp = () => { const app = new Hono(); - // // CORS configuration (must be before routes) - // app.use( - // "*", - // cors({ - // origin: ALLOWED_ORIGINS, - // allowHeaders: ALLOWED_HEADERS, - // allowMethods: ["POST", "GET", "PUT", "DELETE", "PATCH", "OPTIONS"], - // exposeHeaders: ["Content-Length"], - // maxAge: 600, - // credentials: true, - // }), - // ); + // CORS configuration (must be before routes) + app.use( + "*", + cors({ + origin: ALLOWED_ORIGINS, + allowHeaders: ALLOWED_HEADERS, + allowMethods: ["POST", "GET", "PUT", "DELETE", "PATCH", "OPTIONS"], + exposeHeaders: ["Content-Length"], + maxAge: 600, + credentials: true, + }), + ); - // // Better Auth handler - // app.on(["POST", "GET"], "/api/auth/*", (c) => { - // return auth.handler(c.req.raw); - // }); + // Better Auth handler + app.on(["POST", "GET"], "/api/auth/*", (c) => { + return auth.handler(c.req.raw); + }); - // // Step 1: Base middleware - sets up ctx (db, logger, etc.) - // app.use("*", baseMiddleware); + // Step 1: Base middleware - sets up ctx (db, logger, etc.) + app.use("*", baseMiddleware); - // // Step 2: Tracing middleware - handles OpenTelemetry spans - // app.use("*", traceMiddleware); + // Step 2: Tracing middleware - handles OpenTelemetry spans + app.use("*", traceMiddleware); - // // Step 3: API Version middleware - validates x-api-version header - // app.use("/v1/*", apiVersionMiddleware); + // Step 3: API Version middleware - validates x-api-version header + app.use("/v1/*", apiVersionMiddleware); - // // Step 4: Auth middleware - verifies secret key and populates auth context - // app.use("/v1/*", secretKeyMiddleware); + // Step 4: Auth middleware - verifies secret key and populates auth context + app.use("/v1/*", secretKeyMiddleware); - // // Step 5: Org config middleware - allows config overrides via header - // app.use("/v1/*", orgConfigMiddleware); + // Step 5: Org config middleware - allows config overrides via header + app.use("/v1/*", orgConfigMiddleware); // Step 6: Add pricing middleware, analytics middleware, etc. app.post("/v1/products", handleCreateProduct); - app.onError((err, c) => { - console.error(`Route error ${err.message}`); - return c.json({ error: "Internal server error" }, 500); - }); + // Error handler - must be defined after all routes and middleware + app.onError(errorMiddleware); // Create request listener for integration with Express const requestListener = getRequestListener(app.fetch); diff --git a/server/src/internal/admin/withAdminAuth.ts b/server/src/internal/admin/withAdminAuth.ts index b117a40f7..6f99ac786 100644 --- a/server/src/internal/admin/withAdminAuth.ts +++ b/server/src/internal/admin/withAdminAuth.ts @@ -1,8 +1,8 @@ -import { ErrCode } from "@/errors/errCodes.js"; +import { ErrCode } from "@autumn/shared"; +import type { NextFunction } from "express"; import { auth } from "@/utils/auth.js"; import { ADMIN_USER_IDs } from "@/utils/constants.js"; -import { ExtendedRequest } from "@/utils/models/Request.js"; -import { NextFunction } from "express"; +import type { ExtendedRequest } from "@/utils/models/Request.js"; export const withAdminAuth = async (req: any, res: any, next: NextFunction) => { const { logtail: logger, userId } = req as ExtendedRequest; diff --git a/server/src/internal/api/entitled/checkRouter.ts b/server/src/internal/api/entitled/checkRouter.ts index f180249c7..247b4fb3a 100644 --- a/server/src/internal/api/entitled/checkRouter.ts +++ b/server/src/internal/api/entitled/checkRouter.ts @@ -1,7 +1,6 @@ -import { APIVersion, type Feature, FeatureType } from "@autumn/shared"; +import { APIVersion, ErrCode, type Feature, FeatureType } from "@autumn/shared"; import { Router } from "express"; import { StatusCodes } from "http-status-codes"; -import { ErrCode } from "@/errors/errCodes.js"; import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import { orgToVersion } from "@/utils/versionUtils.js"; diff --git a/server/src/internal/customers/add-product/handleCreateCheckout.ts b/server/src/internal/customers/add-product/handleCreateCheckout.ts index ccd6f93c0..3e2a7a19b 100644 --- a/server/src/internal/customers/add-product/handleCreateCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateCheckout.ts @@ -1,6 +1,10 @@ -import { APIVersion, type AttachConfig, SuccessCode } from "@autumn/shared"; +import { + APIVersion, + type AttachConfig, + AutumnError, + SuccessCode, +} from "@autumn/shared"; import type Stripe from "stripe"; -import { ErrCode } from "@/errors/errCodes.js"; import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js"; @@ -8,7 +12,6 @@ import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { pricesContainRecurring } from "@/internal/products/prices/priceUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import { type AttachParams, @@ -44,9 +47,9 @@ export const handleCreateCheckout = async ({ }); if (itemSets.length === 0) { - throw new RecaseError({ - code: ErrCode.ProductHasNoPrices, - message: "Product has no prices", + throw new AutumnError({ + message: `Product ${attachParams.products.map((p) => p.name).join(", ")} has no prices, can't create checkout`, + statusCode: 400, }); } diff --git a/server/src/internal/customers/attach/attachRouter.ts b/server/src/internal/customers/attach/attachRouter.ts index c98bcc252..298528192 100644 --- a/server/src/internal/customers/attach/attachRouter.ts +++ b/server/src/internal/customers/attach/attachRouter.ts @@ -1,14 +1,18 @@ -import { Router } from "express"; -import RecaseError from "@/utils/errorUtils.js"; import { - APIVersion, - AttachConfig, + type AttachConfig, BillingType, - FullCusProduct, + ErrCode, + type FullCusProduct, } from "@autumn/shared"; -import { ErrCode } from "@/errors/errCodes.js"; -import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; - +import { Router } from "express"; +import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js"; +import { + createStripeCusIfNotExists, + getCusPaymentMethod, +} from "@/external/stripe/stripeCusUtils.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { getBillingType, getEntOptions, @@ -16,32 +20,19 @@ import { getProductForPrice, priceIsOneOffAndTiered, } from "@/internal/products/prices/priceUtils.js"; - import { checkStripeProductExists, isFreeProduct, } from "@/internal/products/productUtils.js"; -import { createStripeCli } from "@/external/stripe/utils.js"; -import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js"; +import RecaseError from "@/utils/errorUtils.js"; import { notNullish, notNullOrUndefined, - nullish, nullOrUndefined, } from "@/utils/genUtils.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { orgToVersion } from "@/utils/versionUtils.js"; -import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; -import { AttachBodySchema } from "@autumn/shared"; -import { processAttachBody } from "./attachUtils/attachParams/processAttachBody.js"; -import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js"; -import { handleAttach } from "./handleAttach.js"; import { handleCheckout } from "./checkout/handleCheckout.js"; -import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; -import { attachParamsToCurCusProduct } from "./attachUtils/convertAttachParams.js"; +import { handleAttach } from "./handleAttach.js"; +import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js"; export const attachRouter: Router = Router(); @@ -62,8 +53,8 @@ export const handlePrepaidErrors = async ({ if (billingType === BillingType.UsageInAdvance) { // Get options for price - let priceEnt = getPriceEntitlement(price, entitlements); - let options = getEntOptions(optionsList, priceEnt); + const priceEnt = getPriceEntitlement(price, entitlements); + const options = getEntOptions(optionsList, priceEnt); // 1. If not checkout, quantity should be defined const regularCheckout = useCheckout && !config.invoiceCheckout; @@ -223,7 +214,7 @@ export const createStripePrices = async ({ // const curCusProduct = attachParamsToCurCusProduct({ attachParams }); for (const price of prices) { - let product = getProductForPrice(price, products); + const product = getProductForPrice(price, products); batchPriceUpdates.push( createStripePriceIFNotExist({ diff --git a/server/src/internal/customers/cancel/cancelRouter.ts b/server/src/internal/customers/cancel/cancelRouter.ts index 4cd140a3e..c979d82f3 100644 --- a/server/src/internal/customers/cancel/cancelRouter.ts +++ b/server/src/internal/customers/cancel/cancelRouter.ts @@ -1,11 +1,10 @@ +import { ErrCode, type FullCusProduct } from "@autumn/shared"; +import { Router } from "express"; import { CusService } from "@/internal/customers/CusService.js"; import RecaseError from "@/utils/errorUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { ErrCode, FullCusProduct } from "@autumn/shared"; -import { Router } from "express"; -import { expireCusProduct } from "../handlers/handleCusProductExpired.js"; -import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; +import { routeHandler } from "@/utils/routerUtils.js"; +import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js"; import { handleCancelProduct } from "./handleCancelProduct.js"; const cancelRouter: Router = Router(); @@ -16,8 +15,8 @@ cancelRouter.post("", async (req, res) => res, action: "expire", handler: async (req, res) => { - let { db, orgId, env, logtail: logger } = req; - let { + const { db, orgId, env, logtail: logger } = req; + const { customer_id, product_id, entity_id, @@ -25,10 +24,10 @@ cancelRouter.post("", async (req, res) => prorate: bodyProrate, } = req.body; - let expireImmediately = cancel_immediately || false; - let prorate = notNullish(bodyProrate) ? bodyProrate : true; + const expireImmediately = cancel_immediately || false; + const prorate = notNullish(bodyProrate) ? bodyProrate : true; - let fullCus = await CusService.getFull({ + const fullCus = await CusService.getFull({ db, orgId, idOrInternalId: customer_id, @@ -46,14 +45,14 @@ cancelRouter.post("", async (req, res) => }); } - let cusProducts = fullCus.customer_products; - let entity = fullCus.entity; + const cusProducts = fullCus.customer_products; + const entity = fullCus.entity; - let cusProduct = cusProducts.find( + const cusProduct = cusProducts.find( (cusProduct: FullCusProduct) => - cusProduct.product.id == product_id && + cusProduct.product.id === product_id && (entity - ? cusProduct.internal_entity_id == entity.internal_id + ? cusProduct.internal_entity_id === entity.internal_id : nullish(cusProduct.internal_entity_id)), ); diff --git a/server/src/internal/customers/cusProducts/CusProductService.ts b/server/src/internal/customers/cusProducts/CusProductService.ts index aea457636..f0e7b4b59 100644 --- a/server/src/internal/customers/cusProducts/CusProductService.ts +++ b/server/src/internal/customers/cusProducts/CusProductService.ts @@ -1,28 +1,17 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; - -import RecaseError from "@/utils/errorUtils.js"; import { - AppEnv, - CusProduct, + type AppEnv, + type CusProduct, CusProductStatus, + customerProducts, customers, ErrCode, - FullCusProduct, + type FullCusProduct, + InternalError, products, } from "@autumn/shared"; - -import { customerProducts } from "@autumn/shared"; - -import { - and, - arrayContains, - eq, - inArray, - isNotNull, - ne, - or, - sql, -} from "drizzle-orm"; +import { and, arrayContains, eq, inArray, isNotNull, or } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import RecaseError from "@/utils/errorUtils.js"; export const ACTIVE_STATUSES = [ CusProductStatus.Active, @@ -45,7 +34,7 @@ export const orgOwnsCusProduct = async ({ env: AppEnv; }) => { if (!cusProduct.product) return false; - let product = cusProduct.product; + const product = cusProduct.product; if (product.org_id !== orgId || product.env !== env) { return false; @@ -65,7 +54,7 @@ export const filterByOrgAndEnv = ({ }) => { return cusProducts.filter((cusProduct) => { if (!cusProduct.product) return false; - let product = cusProduct.product; + const product = cusProduct.product; if (product.org_id !== orgId || product.env !== env) { return false; @@ -99,7 +88,7 @@ const getFullCusProdRelations = () => { export class CusProductService { static async getByIdForReset({ db, id }: { db: DrizzleCli; id: string }) { - let cusProduct = await db.query.customerProducts.findFirst({ + const cusProduct = await db.query.customerProducts.findFirst({ where: eq(customerProducts.id, id), with: { customer: true, @@ -135,7 +124,7 @@ export class CusProductService { env: AppEnv; withCustomer?: boolean; }) { - let cusProduct = (await db.query.customerProducts.findFirst({ + const cusProduct = (await db.query.customerProducts.findFirst({ where: eq(customerProducts.id, id), with: { customer: withCustomer ? true : undefined, @@ -196,7 +185,7 @@ export class CusProductService { withCustomer?: boolean; inStatuses?: string[]; }) { - let cusProducts = await db.query.customerProducts.findMany({ + const cusProducts = await db.query.customerProducts.findMany({ where: and( eq(customerProducts.internal_customer_id, internalCustomerId), inStatuses ? inArray(customerProducts.status, inStatuses) : undefined, @@ -236,7 +225,7 @@ export class CusProductService { internalProductId: string; limit?: number; }) { - let data = await db.query.customerProducts.findMany({ + const data = await db.query.customerProducts.findMany({ where: eq(customerProducts.internal_product_id, internalProductId), limit, }); @@ -257,7 +246,7 @@ export class CusProductService { env: AppEnv; limit?: number; }) { - let data = await db + const data = await db .select() .from(customerProducts) .innerJoin( @@ -293,7 +282,7 @@ export class CusProductService { inStatuses?: string[]; }) { // sql`${customerProducts.subscription_ids} @> ${sql`ARRAY[${stripeSubId}]`}`, - let data = await db.query.customerProducts.findMany({ + const data = await db.query.customerProducts.findMany({ where: (table, { and, or, inArray }) => and( or(arrayContains(customerProducts.subscription_ids, [stripeSubId])), @@ -323,7 +312,7 @@ export class CusProductService { }, }); - let cusProducts = data as FullCusProduct[]; + const cusProducts = data as FullCusProduct[]; return filterByOrgAndEnv({ cusProducts, @@ -343,7 +332,7 @@ export class CusProductService { orgId: string; env: AppEnv; }) { - let data = await db.query.customerProducts.findMany({ + const data = await db.query.customerProducts.findMany({ where: (customerProducts, { and, or, eq, sql }) => and( or( @@ -378,7 +367,7 @@ export class CusProductService { }, }); - let cusProducts = data as FullCusProduct[]; + const cusProducts = data as FullCusProduct[]; return filterByOrgAndEnv({ cusProducts, @@ -398,7 +387,7 @@ export class CusProductService { orgId: string; env: AppEnv; }) { - let fullCusProdRelations = { + const fullCusProdRelations = { customer_entitlements: { with: { entitlement: { @@ -418,7 +407,7 @@ export class CusProductService { free_trial: true as const, } as const; - let data = (await db.query.customerProducts.findMany({ + const data = (await db.query.customerProducts.findMany({ where: arrayContains(customerProducts.scheduled_ids, [scheduleId]), with: { product: true, @@ -461,7 +450,7 @@ export class CusProductService { updates: Partial; inStatuses?: string[]; }) { - let updated = await db + const updated = await db .update(customerProducts) .set(updates as any) .where( @@ -474,7 +463,7 @@ export class CusProductService { id: customerProducts.id, }); - let fullUpdated = (await db.query.customerProducts.findMany({ + const fullUpdated = (await db.query.customerProducts.findMany({ where: inArray( customerProducts.id, updated.map((u) => u.id), @@ -497,7 +486,7 @@ export class CusProductService { stripeScheduledId: string; updates: Partial; }) { - let updated = await db + const updated = await db .update(customerProducts) .set(updates as any) .where( @@ -514,7 +503,7 @@ export class CusProductService { id: customerProducts.id, }); - let fullUpdated = (await db.query.customerProducts.findMany({ + const fullUpdated = (await db.query.customerProducts.findMany({ where: inArray( customerProducts.id, updated.map((u) => u.id), @@ -553,7 +542,7 @@ export class CusProductService { internalCustomerId: string; fingerprint?: string; }) { - let data = await db + const data = await db .select() .from(customerProducts) .innerJoin( @@ -587,7 +576,7 @@ export class CusProductService { freeTrialId: string; internalCustomerId: string; }) { - let data = await db.query.customerProducts.findMany({ + const data = await db.query.customerProducts.findMany({ where: and( eq(customerProducts.free_trial_id, freeTrialId), eq(customerProducts.internal_customer_id, internalCustomerId), @@ -614,7 +603,7 @@ export class CusProductService { env: AppEnv; }) { if (productId) { - let res = await db + const res = await db .select({ internal_id: products.internal_id, }) @@ -627,13 +616,12 @@ export class CusProductService { ), ); - let internalProductIds = res.map((r) => r.internal_id); + const internalProductIds = res.map((r) => r.internal_id); if (internalProductIds.length > 100) { - throw new RecaseError({ - message: "Something went wrong... please try again later.", - code: ErrCode.ProductHasCustomers, - statusCode: 400, + throw new InternalError({ + message: + "Can't delete by product when internal product ids length > 100", }); } diff --git a/server/src/internal/customers/handlers/handleTransferProduct.ts b/server/src/internal/customers/handlers/handleTransferProduct.ts index 5f2093fc2..afb65a791 100644 --- a/server/src/internal/customers/handlers/handleTransferProduct.ts +++ b/server/src/internal/customers/handlers/handleTransferProduct.ts @@ -1,15 +1,23 @@ -import RecaseError from "@/utils/errorUtils.js"; +import { AttachScenario, ErrCode } from "@autumn/shared"; +import { + CusProductAlreadyExistsError, + CusProductNotFoundError, +} from "@shared/api/errors/classes/cusProductErrClasses.js"; import { z } from "zod"; -import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; +import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { nullish } from "@/utils/genUtils.js"; +import type { + ExtendedRequest, + ExtendedResponse, +} from "@/utils/models/Request.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { CusService } from "../CusService.js"; -import { AttachScenario, ErrCode } from "@autumn/shared"; -import { CusProductService } from "../cusProducts/CusProductService.js"; -import { nullish } from "@/utils/genUtils.js"; -import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js"; -import { ProductService } from "@/internal/products/ProductService.js"; import { deleteCusCache } from "../cusCache/updateCachedCus.js"; -import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; +import { CusProductService } from "../cusProducts/CusProductService.js"; +import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js"; + const TransferProductSchema = z.object({ from_entity_id: z.string().nullish(), to_entity_id: z.string(), @@ -43,10 +51,9 @@ export const handleTransferProduct = async (req: any, res: any) => }); if (!product) { - throw new RecaseError({ - code: ErrCode.ProductNotFound, - message: `Product ${product_id} not found`, - statusCode: 404, + throw new CusProductNotFoundError({ + customerId: customer_id, + productId: product_id, }); } @@ -88,18 +95,17 @@ export const handleTransferProduct = async (req: any, res: any) => ); if (toCusProduct) { - throw new RecaseError({ - code: ErrCode.ProductAlreadyExists, - message: `Entity ${toEntity.id} already has product ${toCusProduct.product.name}`, - statusCode: 400, + throw new CusProductAlreadyExistsError({ + productId: product_id, + entityId: toEntity.id, }); } if (!cusProduct) { - throw new RecaseError({ - code: ErrCode.CusProductNotFound, - message: `Product ${product_id} not found for entity ${from_entity_id}`, - statusCode: 404, + throw new CusProductNotFoundError({ + customerId: customer_id, + productId: product_id, + entityId: from_entity_id || undefined, }); } diff --git a/server/src/internal/migrations/migrationRouter.ts b/server/src/internal/migrations/migrationRouter.ts index fcad47c88..cdb11f1fa 100644 --- a/server/src/internal/migrations/migrationRouter.ts +++ b/server/src/internal/migrations/migrationRouter.ts @@ -1,4 +1,9 @@ -import { BillingType, ErrCode, type UsagePriceConfig } from "@autumn/shared"; +import { + BillingType, + ErrCode, + ProductNotFoundError, + type UsagePriceConfig, +} from "@autumn/shared"; import express, { type Router } from "express"; import { MigrationService } from "@/internal/migrations/MigrationService.js"; import { constructMigrationJob } from "@/internal/migrations/migrationUtils.js"; @@ -127,10 +132,9 @@ export const handleMigrate = async ( }); if (!fromProduct || !toProduct) { - throw new RecaseError({ - message: `Product ${from_product_id} version ${from_version} or ${to_product_id} version ${to_version} not found`, - code: ErrCode.ProductNotFound, - statusCode: 404, + throw new ProductNotFoundError({ + productId: !fromProduct ? from_product_id : to_product_id, + version: !fromProduct ? from_version : to_version, }); } diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index 46cfda9f1..f2ea4e79f 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -7,6 +7,7 @@ import { type FullProduct, freeTrials, type Product, + ProductNotFoundError, prices, products, } from "@autumn/shared"; @@ -251,11 +252,7 @@ export class ProductService { for (const id of inIds) { const prod = latestProducts.find((prod) => prod.id === id); if (!prod) { - throw new RecaseError({ - message: `Product ${id} not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); + throw new ProductNotFoundError({ productId: id }); } newProducts.push(prod); } @@ -311,11 +308,7 @@ export class ProductService { if (!data) { if (allowNotFound) return null as unknown as FullProduct; - throw new RecaseError({ - message: `Product ${idOrInternalId} not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); + throw new ProductNotFoundError({ productId: idOrInternalId }); } return data as FullProduct; @@ -346,11 +339,7 @@ export class ProductService { }); if (data.length === 0) { - throw new RecaseError({ - message: `Product ${productId} not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); + throw new ProductNotFoundError({ productId: productId }); } return data[0].version; diff --git a/server/src/internal/products/handlers/handleCopyProduct.ts b/server/src/internal/products/handlers/handleCopyProduct.ts index f42183e3a..0ee1f0d01 100644 --- a/server/src/internal/products/handlers/handleCopyProduct.ts +++ b/server/src/internal/products/handlers/handleCopyProduct.ts @@ -1,10 +1,14 @@ -import RecaseError from "@/utils/errorUtils.js"; +import { + CreateFeatureSchema, + ErrCode, + ProductAlreadyExistsError, +} from "@autumn/shared"; import { FeatureService } from "@/internal/features/FeatureService.js"; -import { ProductService } from "@/internal/products/ProductService.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { CreateFeatureSchema, ErrCode } from "@autumn/shared"; import { initNewFeature } from "@/internal/features/internalFeatureRouter.js"; +import { ProductService } from "@/internal/products/ProductService.js"; import { copyProduct } from "@/internal/products/productUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { routeHandler } from "@/utils/routerUtils.js"; export const handleCopyProduct = async (req: any, res: any) => routeHandler({ @@ -12,7 +16,7 @@ export const handleCopyProduct = async (req: any, res: any) => res, action: "Copy Product", handler: async (req, res) => { - let { db, logtail: logger } = req; + const { db, logtail: logger } = req; const { productId: fromProductId } = req.params; const orgId = req.orgId; @@ -27,7 +31,7 @@ export const handleCopyProduct = async (req: any, res: any) => }); } - if (fromEnv == toEnv && fromProductId == toId) { + if (fromEnv === toEnv && fromProductId === toId) { throw new RecaseError({ message: "Product ID already exists", code: ErrCode.InvalidRequest, @@ -44,10 +48,9 @@ export const handleCopyProduct = async (req: any, res: any) => }); if (toProduct) { - throw new RecaseError({ - message: "Product already exists in live... can't copy again", - code: ErrCode.ProductAlreadyExists, - statusCode: 400, + throw new ProductAlreadyExistsError({ + productId: toId, + message: `Product ${toId} already exists in ${toEnv}`, }); } @@ -71,9 +74,9 @@ export const handleCopyProduct = async (req: any, res: any) => }), ]); - if (fromEnv != toEnv) { + if (fromEnv !== toEnv) { for (const fromFeature of fromFeatures) { - const toFeature = toFeatures.find((f) => f.id == fromFeature.id); + const toFeature = toFeatures.find((f) => f.id === fromFeature.id); if (toFeature && fromFeature.type !== toFeature.type) { throw new RecaseError({ @@ -84,7 +87,7 @@ export const handleCopyProduct = async (req: any, res: any) => } if (!toFeature) { - let res = await FeatureService.insert({ + const res = await FeatureService.insert({ db, data: initNewFeature({ data: CreateFeatureSchema.parse(fromFeature), diff --git a/server/src/internal/products/handlers/handleCreateProduct.ts b/server/src/internal/products/handlers/handleCreateProduct.ts index 5f944ebe4..61292e904 100644 --- a/server/src/internal/products/handlers/handleCreateProduct.ts +++ b/server/src/internal/products/handlers/handleCreateProduct.ts @@ -7,6 +7,7 @@ import { type FullProduct, type Price, type Product, + ProductAlreadyExistsError, type ProductItem, } from "@autumn/shared"; import { @@ -56,10 +57,8 @@ const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => { // 1. If existing product, throw error if (existing) { - throw new RecaseError({ - message: `Product ${productData.id} already exists`, - code: ErrCode.ProductAlreadyExists, - statusCode: 400, + throw new ProductAlreadyExistsError({ + productId: productData.id, }); } @@ -69,7 +68,6 @@ const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => { throw new RecaseError({ message: "Items must be an array", code: ErrCode.InvalidRequest, - statusCode: 400, }); } else if (items) { validateProductItems({ diff --git a/server/src/internal/products/handlers/handleDeleteProduct.ts b/server/src/internal/products/handlers/handleDeleteProduct.ts index 746b21d75..0089eb5b4 100644 --- a/server/src/internal/products/handlers/handleDeleteProduct.ts +++ b/server/src/internal/products/handlers/handleDeleteProduct.ts @@ -1,10 +1,7 @@ +import { AutumnError, ProductNotFoundError } from "@autumn/shared"; import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; - import { ProductService } from "@/internal/products/ProductService.js"; -import RecaseError from "@/utils/errorUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; -import { AppEnv, ErrCode } from "@autumn/shared"; export const handleDeleteProduct = (req: any, res: any) => routeHandler({ @@ -24,14 +21,10 @@ export const handleDeleteProduct = (req: any, res: any) => }); if (!product) { - throw new RecaseError({ - message: `Product ${productId} not found`, - code: ErrCode.ProductNotFound, - statusCode: 404, - }); + throw new ProductNotFoundError({ productId: productId }); } - let [latestCounts, allCounts] = await Promise.all([ + const [latestCounts, allCounts] = await Promise.all([ CusProdReadService.getCounts({ db, internalProductId: product.internal_id, @@ -44,38 +37,16 @@ export const handleDeleteProduct = (req: any, res: any) => }), ]); - let deleteAllVersions = all_versions === "true"; - let cusProdCount = deleteAllVersions ? allCounts.all : latestCounts.all; + const deleteAllVersions = all_versions === "true"; + const cusProdCount = deleteAllVersions ? allCounts.all : latestCounts.all; if (cusProdCount > 0) { - throw new RecaseError({ - message: "Cannot delete product with customers", - code: ErrCode.ProductHasCustomers, + throw new AutumnError({ + message: `Product ${productId} has ${cusProdCount} customers (expired or active) on it and therefore cannot be deleted`, statusCode: 400, }); } - // if (cusProdCount > 0 && env == AppEnv.Sandbox) { - // if (cusProdCount > 100) { - // throw new RecaseError({ - // message: - // "Cannot delete this product as it has more than 100 customers on it.", - // code: ErrCode.ProductHasCustomers, - // statusCode: 400, - // }); - // } - - // await CusProductService.deleteByProduct({ - // db, - // productId: deleteAllVersions ? productId : undefined, - // internalProductId: deleteAllVersions - // ? undefined - // : product.internal_id, - // orgId, - // env, - // }); - // } - // 2. Delete prices, entitlements, and product if (deleteAllVersions) { await ProductService.deleteByProductId({ diff --git a/server/src/internal/products/handlers/handleGetProduct.ts b/server/src/internal/products/handlers/handleGetProduct.ts index 6452b2cc8..3b35fce10 100644 --- a/server/src/internal/products/handlers/handleGetProduct.ts +++ b/server/src/internal/products/handlers/handleGetProduct.ts @@ -1,5 +1,4 @@ -import { ErrCode } from "@autumn/shared"; -import { StatusCodes } from "http-status-codes"; +import { ErrCode, ProductNotFoundError } from "@autumn/shared"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; @@ -39,11 +38,7 @@ export const handleGetProduct = async (req: any, res: any) => ]); if (!product) { - throw new RecaseError({ - message: `Product ${productId} not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); + throw new ProductNotFoundError({ productId: productId }); } const schemaVersionInt = schemaVersion ? parseInt(schemaVersion) : 2; diff --git a/server/src/internal/products/handlers/handleGetProductDeleteInfo.ts b/server/src/internal/products/handlers/handleGetProductDeleteInfo.ts index fe5faf45c..f6dee1ca9 100644 --- a/server/src/internal/products/handlers/handleGetProductDeleteInfo.ts +++ b/server/src/internal/products/handlers/handleGetProductDeleteInfo.ts @@ -1,10 +1,9 @@ -import { ExtendedRequest } from "@/utils/models/Request.js"; +import { ProductNotFoundError } from "@autumn/shared"; +import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService.js"; +import { handleRequestError } from "@/utils/errorUtils.js"; +import type { ExtendedRequest } from "@/utils/models/Request.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { ProductService } from "../ProductService.js"; -import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; -import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService.js"; -import { ErrCode } from "@autumn/shared"; -import { StatusCodes } from "http-status-codes"; export const handleGetProductDeleteInfo = async (req: any, res: any) => routeHandler({ @@ -15,7 +14,7 @@ export const handleGetProductDeleteInfo = async (req: any, res: any) => try { // 1. Get number of versions const { db, orgId, env } = req; - let product = await ProductService.get({ + const product = await ProductService.get({ db, id: req.params.productId, orgId: req.orgId, @@ -23,14 +22,10 @@ export const handleGetProductDeleteInfo = async (req: any, res: any) => }); if (!product) { - throw new RecaseError({ - message: `Product ${req.params.productId} not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); + throw new ProductNotFoundError({ productId: req.params.productId }); } - let [allVersions, latestVersion, deletionText] = await Promise.all([ + const [allVersions, latestVersion, deletionText] = await Promise.all([ CusProdReadService.existsForProduct({ db, productId: req.params.productId, diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts index 787ee42e2..3f36c92e5 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts @@ -1,4 +1,8 @@ -import { ErrCode, type FullProduct, UpdateProductSchema } from "@autumn/shared"; +import { + ErrCode, + ProductNotFoundError, + UpdateProductSchema, +} from "@autumn/shared"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; @@ -10,7 +14,6 @@ import { addTaskToQueue } from "@/queue/queueUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; -import { getEntsWithFeature } from "../../entitlements/entitlementUtils.js"; import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js"; import { ProductService } from "../../ProductService.js"; import { productsAreSame } from "../../productUtils/compareProductUtils.js"; @@ -22,7 +25,6 @@ import { } from "../handleCreateProduct.js"; import { handleVersionProductV2 } from "../handleVersionProduct.js"; import { handleUpdateProductDetails } from "./updateProductDetails.js"; -import { formatPrice } from "../../prices/priceUtils.js"; export const handleUpdateProductV2 = async (req: any, res: any) => routeHandler({ @@ -65,11 +67,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) => return; } - throw new RecaseError({ - message: "Product not found", - code: ErrCode.ProductNotFound, - statusCode: 404, - }); + throw new ProductNotFoundError({ productId: productId }); } const cusProductsCurVersion = @@ -80,10 +78,6 @@ export const handleUpdateProductV2 = async (req: any, res: any) => const cusProductExists = cusProductsCurVersion.length > 0; - // console.log("Updating product", { - // id: fullProduct.id, - // body: req.body, - // }); await disableCurrentDefault({ req, newProduct: { diff --git a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts index dd60e2de0..3e2f011ab 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts @@ -1,31 +1,28 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; import { - AppEnv, - CreateFreeTrial, - ErrCode, - FreeTrial, - FullProduct, + type AppEnv, + AutumnError, + type FreeTrial, + type FullProduct, isFreeProductV2, - Organization, - Product, - ProductItem, - RewardProgram, - UpdateProduct, + type Organization, + type Product, + type ProductItem, + type RewardProgram, + type UpdateProduct, } from "@autumn/shared"; -import { ProductService } from "../../ProductService.js"; -import { FreeTrialService } from "../../free-trials/FreeTrialService.js"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/stripe/utils.js"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; +import { isStripeConnected } from "@/internal/orgs/orgUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; +import { FreeTrialService } from "../../free-trials/FreeTrialService.js"; +import { ProductService } from "../../ProductService.js"; import { usagePriceToProductName } from "../../prices/priceUtils/usagePriceUtils/convertUsagePrice.js"; import { isFeaturePriceItem, isPriceItem, } from "../../product-items/productItemUtils/getItemType.js"; import { isFreeProduct } from "../../productUtils.js"; -import { isStripeConnected } from "@/internal/orgs/orgUtils.js"; -import { isDefaultTrialFullProduct } from "../../productUtils/classifyProduct.js"; const productDetailsSame = (prod1: Product, prod2: UpdateProduct) => { if (notNullish(prod2.id) && prod1.id != prod2.id) { @@ -74,7 +71,7 @@ const updateStripeProductNames = async ({ org, env: curProduct.env as AppEnv, }); - let stripeProdId = curProduct.processor?.id; + const stripeProdId = curProduct.processor?.id; if (!stripeProdId || !newName) { return; @@ -96,10 +93,10 @@ const updateStripeProductNames = async ({ } for (const price of curProduct.prices) { - let stripeProdId = price.config?.stripe_product_id; + const stripeProdId = price.config?.stripe_product_id; if (stripeProdId) { - let name = usagePriceToProductName({ + const name = usagePriceToProductName({ price, fullProduct: { ...curProduct, @@ -194,31 +191,20 @@ export const handleUpdateProductDetails = async ({ newFreeTrial, newItems: items, }) - // && !isDefaultTrialFullProduct({ - // product: { - // ...newProduct, - // free_trial: newFreeTrial || curProduct.free_trial || null, - // }, - // skipDefault: true, - // }) ) { // 1. Check if there are items if (items) { if (items.some((item) => isFeaturePriceItem(item) || isPriceItem(item))) { - throw new RecaseError({ + throw new AutumnError({ message: "Cannot make a product default if it has fixed prices or paid features", - code: ErrCode.InvalidProduct, - statusCode: 400, }); } } else { if (!isFreeProduct(curProduct.prices)) { - throw new RecaseError({ + throw new AutumnError({ message: "Cannot make a product default if it has fixed prices or paid features", - code: ErrCode.InvalidProduct, - statusCode: 400, }); } } @@ -230,19 +216,15 @@ export const handleUpdateProductDetails = async ({ if (notNullish(newProduct.id) && newProduct.id !== curProduct.id) { if (customersOnAllVersions.length > 0) { - throw new RecaseError({ + throw new AutumnError({ message: "Cannot change product ID because it has existing customers", - code: ErrCode.ProductHasCustomers, - statusCode: 400, }); } if (rewardPrograms.length > 0) { - throw new RecaseError({ + throw new AutumnError({ message: "Cannot change product ID because existing reward programs are linked to it", - code: ErrCode.ProductHasRewardPrograms, - statusCode: 400, }); } } diff --git a/server/src/internal/products/honoProductRouter.ts b/server/src/internal/products/honoProductRouter.ts index 33b2ac177..3f6f1066b 100644 --- a/server/src/internal/products/honoProductRouter.ts +++ b/server/src/internal/products/honoProductRouter.ts @@ -1,22 +1,29 @@ // import { zValidator } from "@hono/zod-validator"; -import { ErrCode } from "@autumn/shared"; +import { ProductNotFoundError } from "@autumn/shared"; import type { Context } from "hono"; +import { createStripeCli } from "@/external/stripe/utils.js"; import type { HonoEnv } from "@/initHono.js"; -import RecaseError from "@/utils/errorUtils.js"; export const handleCreateProduct = async (c: Context) => { const body1 = await c.req.json(); const body2 = await c.req.json(); - console.log("Body1:", body1); - console.log("Body2:", body2); - const ctx = c.get("ctx"); - throw new RecaseError({ - message: "Test error", - code: ErrCode.InvalidRequest, - statusCode: 400, - }); + const ctx = c.get("ctx"); + const { org, env } = ctx; + + const stripe = createStripeCli({ org, env }); + + try { + const product = await stripe.products.retrieve("123"); + } catch (error: any) { + console.log(error.message, error.code); + } + + throw new ProductNotFoundError({ productId: "123" }); + // return c.json({ message: "Hello from Hono!" }); + + // throw new ProductNotFoundError({ productId: "123" }); // Get parsed body from context (already parsed by wrapExpressMiddleware) // const body = c.get("parsedBody"); diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 185948f29..ff26b6b05 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -1,7 +1,10 @@ -import { ErrCode, type FeatureOptions, UsageModel } from "@autumn/shared"; +import { + type FeatureOptions, + ProductNotFoundError, + UsageModel, +} from "@autumn/shared"; import { Router } from "express"; -import { StatusCodes } from "http-status-codes"; -import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; +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"; @@ -147,13 +150,7 @@ productRouter.get("/:productId/data2", async (req: any, res) => { ]); if (!product) { - throw new RecaseError({ - message: `Product ${productId} ${ - version ? `(v${version})` : "" - } not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); + throw new ProductNotFoundError({ productId, version }); } const productV2 = mapToProductV2({ @@ -186,13 +183,7 @@ productRouter.get("/:productId/count", async (req: any, res) => { }); if (!product) { - throw new RecaseError({ - message: `Product ${productId} ${ - version ? `(v${version})` : "" - } not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); + throw new ProductNotFoundError({ productId, version }); } // Get counts from postgres @@ -413,13 +404,7 @@ productRouter.get("/:productId/data", async (req: any, res) => { ]); if (!product) { - throw new RecaseError({ - message: `Product ${productId} ${ - version ? `(v${version})` : "" - } not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); + throw new ProductNotFoundError({ productId, version }); } const defaultProds = await ProductService.listDefault({ diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts index 57676eff7..912d9427d 100644 --- a/server/src/internal/products/productRouter.ts +++ b/server/src/internal/products/productRouter.ts @@ -1,4 +1,4 @@ -import { ErrCode } from "@autumn/shared"; +import { ProductNotFoundError } from "@autumn/shared"; import { Router } from "express"; import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js"; @@ -6,7 +6,7 @@ import { createStripeCli } from "@/external/stripe/utils.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { checkStripeProductExists } from "@/internal/products/productUtils.js"; -import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; +import { handleRequestError } from "@/utils/errorUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { CusProductService } from "../customers/cusProducts/CusProductService.js"; import { handleCopyProduct } from "./handlers/handleCopyProduct.js"; @@ -121,11 +121,7 @@ productRouter.get("/:productId/has_customers", async (req: any, res: any) => }); if (!product) { - throw new RecaseError({ - message: `Product with id ${productId} not found`, - code: ErrCode.ProductNotFound, - statusCode: 404, - }); + throw new ProductNotFoundError({ productId }); } const cusProductsCurVersion = diff --git a/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts b/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts index 2804630ae..aa15cc92c 100644 --- a/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts +++ b/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts @@ -3,6 +3,7 @@ import { type Customer, ErrCode, type FullRewardProgram, + ProductNotFoundError, type ReferralCode, type Reward, RewardReceivedBy, @@ -89,11 +90,7 @@ export const triggerFreeProduct = async ({ // !isOneOff(fullProduct.prices) && !itemsAreOneOff(fullProduct.entitlements); if (!fullProduct) { - throw new RecaseError({ - message: `Product ${productId} not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); + throw new ProductNotFoundError({ productId: productId }); } const [fullReferrer, fullRedeemer] = await Promise.all([ diff --git a/shared/api/errors/archivedCodes/auth.ts b/shared/api/errors/archivedCodes/auth.ts new file mode 100644 index 000000000..8f1a98bf5 --- /dev/null +++ b/shared/api/errors/archivedCodes/auth.ts @@ -0,0 +1,26 @@ +/** + * Authentication and authorization error codes + */ +export const AuthErrorCode = { + // API Version + InvalidApiVersion: "invalid_api_version", + + // Secret Key Auth + NoSecretKey: "no_secret_key", + InvalidSecretKey: "invalid_secret_key", + FailedToVerifySecretKey: "failed_to_verify_secret_key", + FailedToFetchKeyFromAutumn: "failed_to_fetch_key_from_autumn", + + // Publishable Key Auth + NoPublishableKey: "no_publishable_key", + InvalidPublishableKey: "invalid_publishable_key", + GetOrgFromPublishableKeyFailed: "get_org_from_publishable_key_failed", + EndpointNotPublic: "endpoint_not_public", + FailedToVerifyPublishableKey: "failed_to_verify_publishable_key", + + // General Auth + NoAuthHeader: "no_auth_header", + InvalidAuthHeader: "invalid_auth_header", +} as const; + +export type AuthErrorCode = (typeof AuthErrorCode)[keyof typeof AuthErrorCode]; diff --git a/shared/api/errors/archivedCodes/customer.ts b/shared/api/errors/archivedCodes/customer.ts new file mode 100644 index 000000000..b0645c34f --- /dev/null +++ b/shared/api/errors/archivedCodes/customer.ts @@ -0,0 +1,21 @@ +/** + * Customer-related error codes + */ +export const CustomerErrorCode = { + InvalidCustomer: "invalid_customer", + CreateCustomerFailed: "create_customer_failed", + CustomerNotFound: "customer_not_found", + CustomersNotFound: "customers_not_found", + CustomerAlreadyHasProduct: "customer_already_has_product", + CustomerHasNoPaymentMethod: "customer_has_no_payment_method", + CustomerHasNoBaseProduct: "customer_has_no_base_product", + AttachProductToCustomerFailed: "attach_product_to_customer_failed", + CustomerEntitlementNotFound: "customer_entitlement_not_found", + MultipleCustomersFound: "multiple_customers_found", + InvalidUpdateCustomerParams: "invalid_update_customer_params", + DuplicateCustomerId: "duplicate_customer_id", + DuplicateCustomerEmail: "duplicate_customer_email", +} as const; + +export type CustomerErrorCode = + (typeof CustomerErrorCode)[keyof typeof CustomerErrorCode]; diff --git a/shared/api/errors/archivedCodes/general.ts b/shared/api/errors/archivedCodes/general.ts new file mode 100644 index 000000000..8cfbafdec --- /dev/null +++ b/shared/api/errors/archivedCodes/general.ts @@ -0,0 +1,85 @@ +/** + * General application error codes + */ +export const GeneralErrorCode = { + // Request validation + InvalidInputs: "invalid_inputs", + InvalidRequest: "invalid_request", + InvalidExpand: "invalid_expand", + InvalidOptions: "invalid_options", + + // Internal errors + InternalError: "internal_error", + + // Organization + OrgNotFound: "org_not_found", + OrgHasCustomers: "org_has_customers", + + // Features + FeatureNotFound: "feature_not_found", + InvalidFeature: "invalid_feature", + DuplicateFeatureId: "duplicate_feature_id", + InvalidEventName: "invalid_event_name", + FeatureLimitReached: "feature_limit_reached", + + // Entitlements + InvalidEntitlement: "invalid_entitlement", + CreateEntitlementFailed: "create_entitlement_failed", + DeleteEntitlementFailed: "delete_entitlement_failed", + InsufficientBalance: "insufficient_balance", + + // Invoices + CreateInvoiceFailed: "create_invoice_failed", + PayInvoiceFailed: "invoice_payment_failed", + + // Events + InvalidEvent: "invalid_event", + CreateEventFailed: "create_event_failed", + DuplicateEvent: "duplicate_event", + + // Customer Products + CusProductNotFound: "cus_product_not_found", + NoActiveCusProducts: "no_active_cus_products", + GetCusProductsFailed: "get_cus_products_failed", + + // Entities + EntityNotFound: "entity_not_found", + EntityAlreadyDeleted: "entity_already_deleted", + EntityIdRequired: "entity_id_required", + EntityBalanceNotFound: "entity_balance_not_found", + + // Subscriptions + InsertSubscriptionFailed: "insert_subscription_failed", + UpdateSubscriptionFailed: "update_subscription_failed", + + // Rewards + InvalidReward: "invalid_reward", + PromoCodeAlreadyExistsInStripe: "promo_code_already_exists_in_stripe", + RewardNotFound: "reward_not_found", + RewardProgramNotFound: "reward_program_not_found", + InsertRewardProgramFailed: "insert_reward_program_failed", + InsertReferralCodeFailed: "insert_referral_code_failed", + ReferralCodeNotFound: "referral_code_not_found", + UpdateRewardRedemptionFailed: "update_reward_redemption_failed", + RewardRedemptionNotFound: "reward_redemption_not_found", + InsertRewardRedemptionFailed: "insert_reward_redemption_failed", + + // Referral codes + ReferralCodeMaxRedemptionsReached: "referral_code_max_redemptions_reached", + ReferralNotFound: "referral_not_found", + CustomerAlreadyRedeemedReferralCode: + "customer_already_redeemed_referral_code", + CustomerCannotRedeemOwnCode: "customer_cannot_redeem_own_code", + + // Migration + InsertMigrationJobFailed: "insert_migration_job_failed", + InsertMigrationErrorFailed: "insert_migration_error_failed", + MigrationJobNotFound: "migration_job_not_found", + + // External services + SupabaseNotFound: "supabase_not_found", + ClickHouseDisabled: "clickhouse_disabled", +} as const; + +export type GeneralErrorCode = + (typeof GeneralErrorCode)[keyof typeof GeneralErrorCode]; diff --git a/shared/api/errors/archivedCodes/price.ts b/shared/api/errors/archivedCodes/price.ts new file mode 100644 index 000000000..f06d67d40 --- /dev/null +++ b/shared/api/errors/archivedCodes/price.ts @@ -0,0 +1,16 @@ +/** + * Price-related error codes + */ +export const PriceErrorCode = { + PriceNotFound: "price_not_found", + CreatePriceFailed: "create_price_failed", + InvalidPrice: "invalid_price", + InvalidPriceId: "invalid_price_id", + InvalidPriceOptions: "invalid_price_options", + InvalidPriceConfig: "invalid_price_config", + CusPriceNotFound: "cus_price_not_found", + GetCusPriceFailed: "get_cus_price_failed", +} as const; + +export type PriceErrorCode = + (typeof PriceErrorCode)[keyof typeof PriceErrorCode]; diff --git a/shared/api/errors/archivedCodes/stripe.ts b/shared/api/errors/archivedCodes/stripe.ts new file mode 100644 index 000000000..640c2ec5b --- /dev/null +++ b/shared/api/errors/archivedCodes/stripe.ts @@ -0,0 +1,25 @@ +/** + * Stripe integration error codes + */ +export const StripeErrorCode = { + StripeError: "stripe_error", + StripeKeyInvalid: "stripe_key_invalid", + StripeKeyNotFound: "stripe_key_not_found", + StripeConfigNotFound: "stripe_config_not_found", + + // Stripe Operations + StripeDeleteCustomerFailed: "stripe_delete_customer_failed", + StripeCreateCustomerFailed: "stripe_create_customer_failed", + StripeCreateProductFailed: "stripe_create_product_failed", + StripeCancelSubscriptionFailed: "stripe_cancel_subscription_failed", + StripeGetPaymentMethodFailed: "stripe_get_payment_method_failed", + StripeCardDeclined: "stripe_card_declined", + StripeUpdateSubscriptionFailed: "stripe_update_subscription_failed", + StripeCancelSubscriptionScheduleFailed: + "stripe_cancel_subscription_schedule_failed", + StripeCreateSubscriptionFailed: "stripe_create_subscription_failed", + CreateStripeSubscriptionFailed: "create_stripe_subscription_failed", +} as const; + +export type StripeErrorCode = + (typeof StripeErrorCode)[keyof typeof StripeErrorCode]; diff --git a/shared/api/errors/archives/auth.ts b/shared/api/errors/archives/auth.ts new file mode 100644 index 000000000..47eae70ae --- /dev/null +++ b/shared/api/errors/archives/auth.ts @@ -0,0 +1,114 @@ +// import { AuthErrorCode } from "../archivedCodes/auth.js"; +// import { AutumnError } from "./base.js"; + +// /** +// * No secret key provided error +// */ +// export class NoSecretKeyError extends AutumnError { +// constructor(message = "No secret key provided") { +// super({ +// message, +// code: AuthErrorCode.NoSecretKey, +// statusCode: 401, +// }); +// this.name = "NoSecretKeyError"; +// } +// } + +// /** +// * Invalid secret key error +// */ +// export class InvalidSecretKeyError extends AutumnError { +// constructor(message = "Invalid secret key") { +// super({ +// message, +// code: AuthErrorCode.InvalidSecretKey, +// statusCode: 401, +// }); +// this.name = "InvalidSecretKeyError"; +// } +// } + +// /** +// * No auth header error +// */ +// export class NoAuthHeaderError extends AutumnError { +// constructor(message = "No authorization header provided") { +// super({ +// message, +// code: AuthErrorCode.NoAuthHeader, +// statusCode: 401, +// }); +// this.name = "NoAuthHeaderError"; +// } +// } + +// /** +// * Invalid auth header error +// */ +// export class InvalidAuthHeaderError extends AutumnError { +// constructor(message = "Invalid authorization header") { +// super({ +// message, +// code: AuthErrorCode.InvalidAuthHeader, +// statusCode: 401, +// }); +// this.name = "InvalidAuthHeaderError"; +// } +// } + +// /** +// * Invalid API version error +// */ +// export class InvalidApiVersionError extends AutumnError { +// constructor(version: string) { +// super({ +// message: `Invalid API version: ${version}`, +// code: AuthErrorCode.InvalidApiVersion, +// statusCode: 400, +// }); +// this.name = "InvalidApiVersionError"; +// } +// } + +// /** +// * No publishable key error +// */ +// export class NoPublishableKeyError extends AutumnError { +// constructor(message = "No publishable key provided") { +// super({ +// message, +// code: AuthErrorCode.NoPublishableKey, +// statusCode: 401, +// }); +// this.name = "NoPublishableKeyError"; +// } +// } + +// /** +// * Invalid publishable key error +// */ +// export class InvalidPublishableKeyError extends AutumnError { +// constructor(message = "Invalid publishable key") { +// super({ +// message, +// code: AuthErrorCode.InvalidPublishableKey, +// statusCode: 401, +// }); +// this.name = "InvalidPublishableKeyError"; +// } +// } + +// /** +// * Endpoint not public error +// */ +// export class EndpointNotPublicError extends AutumnError { +// constructor(message = "This endpoint is not public") { +// super({ +// message, +// code: AuthErrorCode.EndpointNotPublic, +// statusCode: 403, +// }); +// this.name = "EndpointNotPublicError"; +// } +// } diff --git a/shared/api/errors/archives/customer.ts b/shared/api/errors/archives/customer.ts new file mode 100644 index 000000000..fd7f87336 --- /dev/null +++ b/shared/api/errors/archives/customer.ts @@ -0,0 +1,100 @@ +// import { CustomerErrorCode } from "../archivedCodes/customer.js"; +// import { AutumnError } from "./base.js"; + +// /** +// * Customer not found error +// */ +// export class CustomerNotFoundError extends AutumnError { +// constructor(customerId: string) { +// super({ +// message: `Customer ${customerId} not found`, +// code: CustomerErrorCode.CustomerNotFound, +// statusCode: 404, +// }); +// this.name = "CustomerNotFoundError"; +// } +// } + +// /** +// * Invalid customer error +// */ +// export class InvalidCustomerError extends AutumnError { +// constructor(message = "Invalid customer") { +// super({ +// message, +// code: CustomerErrorCode.InvalidCustomer, +// statusCode: 400, +// }); +// this.name = "InvalidCustomerError"; +// } +// } + +// /** +// * Customer already has product error +// */ +// export class CustomerAlreadyHasProductError extends AutumnError { +// constructor(customerId: string, productId: string) { +// super({ +// message: `Customer ${customerId} already has product ${productId}`, +// code: CustomerErrorCode.CustomerAlreadyHasProduct, +// statusCode: 400, +// }); +// this.name = "CustomerAlreadyHasProductError"; +// } +// } + +// /** +// * Customer has no payment method error +// */ +// export class CustomerHasNoPaymentMethodError extends AutumnError { +// constructor(message = "Customer has no payment method") { +// super({ +// message, +// code: CustomerErrorCode.CustomerHasNoPaymentMethod, +// statusCode: 400, +// }); +// this.name = "CustomerHasNoPaymentMethodError"; +// } +// } + +// /** +// * Multiple customers found error +// */ +// export class MultipleCustomersFoundError extends AutumnError { +// constructor(message = "Multiple customers found") { +// super({ +// message, +// code: CustomerErrorCode.MultipleCustomersFound, +// statusCode: 400, +// }); +// this.name = "MultipleCustomersFoundError"; +// } +// } + +// /** +// * Duplicate customer ID error +// */ +// export class DuplicateCustomerIdError extends AutumnError { +// constructor(customerId: string) { +// super({ +// message: `Customer ${customerId} already exists`, +// code: CustomerErrorCode.DuplicateCustomerId, +// statusCode: 400, +// }); +// this.name = "DuplicateCustomerIdError"; +// } +// } + +// /** +// * Duplicate customer email error +// */ +// export class DuplicateCustomerEmailError extends AutumnError { +// constructor(email: string) { +// super({ +// message: `Customer with email ${email} already exists`, +// code: CustomerErrorCode.DuplicateCustomerEmail, +// statusCode: 400, +// }); +// this.name = "DuplicateCustomerEmailError"; +// } +// } diff --git a/shared/api/errors/archives/general.ts b/shared/api/errors/archives/general.ts new file mode 100644 index 000000000..88609c2e3 --- /dev/null +++ b/shared/api/errors/archives/general.ts @@ -0,0 +1,114 @@ +// import { GeneralErrorCode } from "../archivedCodes/general.js"; +// import { AutumnError } from "./base.js"; + +// /** +// * Invalid request error +// */ +// export class InvalidRequestError extends AutumnError { +// constructor(message = "Invalid request") { +// super({ +// message, +// code: GeneralErrorCode.InvalidRequest, +// statusCode: 400, +// }); +// this.name = "InvalidRequestError"; +// } +// } + +// /** +// * Invalid inputs error (typically for validation failures) +// */ +// export class InvalidInputsError extends AutumnError { +// constructor(message = "Invalid inputs") { +// super({ +// message, +// code: GeneralErrorCode.InvalidInputs, +// statusCode: 400, +// }); +// this.name = "InvalidInputsError"; +// } +// } + +// /** +// * Internal server error +// */ +// export class InternalError extends AutumnError { +// constructor(message = "Internal server error") { +// super({ +// message, +// code: GeneralErrorCode.InternalError, +// statusCode: 500, +// }); +// this.name = "InternalError"; +// } +// } + +// /** +// * Organization not found error +// */ +// export class OrgNotFoundError extends AutumnError { +// constructor(message = "Organization not found") { +// super({ +// message, +// code: GeneralErrorCode.OrgNotFound, +// statusCode: 404, +// }); +// this.name = "OrgNotFoundError"; +// } +// } + +// /** +// * Feature not found error +// */ +// export class FeatureNotFoundError extends AutumnError { +// constructor(featureId: string) { +// super({ +// message: `Feature ${featureId} not found`, +// code: GeneralErrorCode.FeatureNotFound, +// statusCode: 404, +// }); +// this.name = "FeatureNotFoundError"; +// } +// } + +// /** +// * Invalid feature error +// */ +// export class InvalidFeatureError extends AutumnError { +// constructor(message = "Invalid feature") { +// super({ +// message, +// code: GeneralErrorCode.InvalidFeature, +// statusCode: 400, +// }); +// this.name = "InvalidFeatureError"; +// } +// } + +// /** +// * Entity not found error +// */ +// export class EntityNotFoundError extends AutumnError { +// constructor(entityId: string) { +// super({ +// message: `Entity ${entityId} not found`, +// code: GeneralErrorCode.EntityNotFound, +// statusCode: 404, +// }); +// this.name = "EntityNotFoundError"; +// } +// } + +// /** +// * Insufficient balance error +// */ +// export class InsufficientBalanceError extends AutumnError { +// constructor(message = "Insufficient balance") { +// super({ +// message, +// code: GeneralErrorCode.InsufficientBalance, +// statusCode: 400, +// }); +// this.name = "InsufficientBalanceError"; +// } +// } diff --git a/shared/api/errors/archives/price.ts b/shared/api/errors/archives/price.ts new file mode 100644 index 000000000..bb429ecca --- /dev/null +++ b/shared/api/errors/archives/price.ts @@ -0,0 +1,58 @@ +// import { PriceErrorCode } from "../archivedCodes/price.js"; +// import { AutumnError } from "./base.js"; + +// /** +// * Price not found error +// */ +// export class PriceNotFoundError extends AutumnError { +// constructor(priceId: string) { +// super({ +// message: `Price ${priceId} not found`, +// code: PriceErrorCode.PriceNotFound, +// statusCode: 404, +// }); +// this.name = "PriceNotFoundError"; +// } +// } + +// /** +// * Invalid price error +// */ +// export class InvalidPriceError extends AutumnError { +// constructor(message = "Invalid price") { +// super({ +// message, +// code: PriceErrorCode.InvalidPrice, +// statusCode: 400, +// }); +// this.name = "InvalidPriceError"; +// } +// } + +// /** +// * Invalid price config error +// */ +// export class InvalidPriceConfigError extends AutumnError { +// constructor(message = "Invalid price configuration") { +// super({ +// message, +// code: PriceErrorCode.InvalidPriceConfig, +// statusCode: 400, +// }); +// this.name = "InvalidPriceConfigError"; +// } +// } + +// /** +// * Invalid price options error +// */ +// export class InvalidPriceOptionsError extends AutumnError { +// constructor(message = "Invalid price options") { +// super({ +// message, +// code: PriceErrorCode.InvalidPriceOptions, +// statusCode: 400, +// }); +// this.name = "InvalidPriceOptionsError"; +// } +// } diff --git a/shared/api/errors/base/AutumnError.ts b/shared/api/errors/base/AutumnError.ts new file mode 100644 index 000000000..668a39fa5 --- /dev/null +++ b/shared/api/errors/base/AutumnError.ts @@ -0,0 +1,27 @@ +/** + * Base error class for all Autumn API errors + * This should match the RecaseError interface from the server + */ +export class AutumnError extends Error { + code: string; + statusCode: number; + data?: unknown; + + constructor({ + message, + code, + statusCode = 400, + data, + }: { + message: string; + code?: string; + statusCode?: number; + data?: unknown; + }) { + super(message); + this.name = "AutumnError"; + this.code = code || "invalid_request"; + this.statusCode = statusCode; + this.data = data; + } +} diff --git a/shared/api/errors/base/InternalError.ts b/shared/api/errors/base/InternalError.ts new file mode 100644 index 000000000..58a4a1c76 --- /dev/null +++ b/shared/api/errors/base/InternalError.ts @@ -0,0 +1,27 @@ +/** + * Base error class for all Autumn API errors + * This should match the RecaseError interface from the server + */ +export class InternalError extends Error { + code: string; + statusCode: number; + data?: unknown; + + constructor({ + message, + code, + statusCode = 500, + data, + }: { + message: string; + code?: string; + statusCode?: number; + data?: unknown; + }) { + super(message); + this.name = "InternalError"; + this.code = code || "internal_error"; + this.statusCode = statusCode; + this.data = data; + } +} diff --git a/shared/api/errors/classes/cusProductErrClasses.ts b/shared/api/errors/classes/cusProductErrClasses.ts new file mode 100644 index 000000000..477daf169 --- /dev/null +++ b/shared/api/errors/classes/cusProductErrClasses.ts @@ -0,0 +1,43 @@ +import { AutumnError } from "../base/AutumnError.js"; +import { CusProductErrorCode } from "../codes/cusProductErrCodes.js"; + +/** + * Product not found error + */ +export class CusProductNotFoundError extends AutumnError { + constructor(opts: { + customerId: string; + productId: string; + entityId?: string; + }) { + const message = opts.entityId + ? `Product ${opts.productId} not found for entity ${opts.entityId}` + : `Product ${opts.productId} not found for customer ${opts.customerId}`; + + super({ + message, + code: CusProductErrorCode.CustomerProductNotFound, + statusCode: 404, + }); + this.name = "CustomerProductNotFoundError"; + } +} + +export class CusProductAlreadyExistsError extends AutumnError { + constructor(opts: { + productId: string; + customerId?: string; + entityId?: string; + }) { + const message = opts.entityId + ? `Entity ${opts.entityId} already has product ${opts.productId}` + : `Customer ${opts.customerId} already has product ${opts.productId}`; + + super({ + message, + code: CusProductErrorCode.CustomerProductAlreadyExists, + statusCode: 400, + }); + this.name = "CusProductAlreadyExistsError"; + } +} diff --git a/shared/api/errors/classes/productErrClasses.ts b/shared/api/errors/classes/productErrClasses.ts new file mode 100644 index 000000000..6af8ad3f0 --- /dev/null +++ b/shared/api/errors/classes/productErrClasses.ts @@ -0,0 +1,30 @@ +import { AutumnError } from "../base/AutumnError.js"; +import { ProductErrorCode } from "../codes/productErrCodes.js"; + +/** + * Product not found error + */ +export class ProductNotFoundError extends AutumnError { + constructor(opts: { productId: string; version?: string }) { + super({ + message: `Product ${opts.productId} ${opts.version ? ` (version ${opts.version})` : ""} not found`, + code: ProductErrorCode.ProductNotFound, + statusCode: 404, + }); + this.name = "ProductNotFoundError"; + } +} + +/** + * Product already exists error + */ +export class ProductAlreadyExistsError extends AutumnError { + constructor(opts: { productId: string; message?: string }) { + super({ + message: opts.message || `Product ${opts.productId} already exists`, + code: ProductErrorCode.ProductAlreadyExists, + statusCode: 400, + }); + this.name = "ProductAlreadyExistsError"; + } +} diff --git a/shared/api/errors/codes/cusProductErrCodes.ts b/shared/api/errors/codes/cusProductErrCodes.ts new file mode 100644 index 000000000..0c1bc7441 --- /dev/null +++ b/shared/api/errors/codes/cusProductErrCodes.ts @@ -0,0 +1,4 @@ +export const CusProductErrorCode = { + CustomerProductNotFound: "customer_product_not_found", + CustomerProductAlreadyExists: "customer_product_already_exists", +} as const; diff --git a/shared/api/errors/codes/productErrCodes.ts b/shared/api/errors/codes/productErrCodes.ts new file mode 100644 index 000000000..748f8e463 --- /dev/null +++ b/shared/api/errors/codes/productErrCodes.ts @@ -0,0 +1,11 @@ +/** + * Product-related error codes + */ +export const ProductErrorCode = { + ProductNotFound: "product_not_found", + ProductAlreadyExists: "product_already_exists", + InvalidProductItem: "invalid_product_item", +} as const; + +export type ProductErrorCode = + (typeof ProductErrorCode)[keyof typeof ProductErrorCode]; diff --git a/shared/api/errors/index.ts b/shared/api/errors/index.ts new file mode 100644 index 000000000..4ab5a98c5 --- /dev/null +++ b/shared/api/errors/index.ts @@ -0,0 +1,5 @@ +export * from "./base/AutumnError.js"; +export * from "./base/InternalError.js"; + +export * from "./classes/productErrClasses.js"; +export * from "./codes/productErrCodes.js"; diff --git a/shared/api/models.ts b/shared/api/models.ts index d86018803..d97fa18f6 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -9,3 +9,6 @@ export * from "./products/operations/createProductParams.js"; // export * from "./products/apiFreeTrial.js"; // export * from "./products/apiProduct.js"; // export * from "./products/apiProductItem.js"; + +// Errors +export * from "./errors/index.js"; diff --git a/shared/api/openApiHelpers.ts b/shared/api/openApiHelpers.ts new file mode 100644 index 000000000..b40395442 --- /dev/null +++ b/shared/api/openApiHelpers.ts @@ -0,0 +1,50 @@ +/** + * Standard error responses for OpenAPI endpoints + * Use this to keep error responses consistent across all endpoints + */ +export const standardErrorResponses = { + "400": { + description: "Bad Request", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AutumnError" }, + }, + }, + }, + "401": { + description: "Unauthorized", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AutumnError" }, + }, + }, + }, + "404": { + description: "Not Found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AutumnError" }, + }, + }, + }, + "500": { + description: "Internal Server Error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AutumnError" }, + }, + }, + }, +} as const; + +/** + * Merge standard error responses with custom success responses + */ +export function withErrorResponses>( + successResponses: T, +) { + return { + ...successResponses, + ...standardErrorResponses, + }; +} diff --git a/shared/api/openapi.ts b/shared/api/openapi.ts index 4d5318265..1680a2d79 100644 --- a/shared/api/openapi.ts +++ b/shared/api/openapi.ts @@ -1,5 +1,7 @@ import { writeFileSync } from "node:fs"; +import { AppEnv } from "@models/genModels/genEnums.js"; import yaml from "yaml"; +import { z } from "zod/v4"; import { createDocument } from "zod-openapi"; import { APIProductSchema } from "./products/apiProduct.js"; import { CreateProductParamsSchema } from "./products/operations/createProductParams.js"; @@ -12,18 +14,32 @@ const document = createDocument({ title: "Autumn API", version: API_VERSION, }, + servers: [ { url: "https://api.useautumn.com", description: "Production server", }, ], + security: [ { secretKey: [], }, ], components: { + schemas: { + autumnError: z + .object({ + message: z.string(), + code: z.string(), + env: z.enum(AppEnv), + }) + .meta({ + id: "AutumnError", + description: "An error that occurred in the API", + }), + }, securitySchemes: { secretKey: { type: "http", @@ -51,6 +67,14 @@ const document = createDocument({ }, }, }, + // responses: withErrorResponses({ + // "200": { + // description: "200 OK", + // content: { + // "application/json": { schema: APIProductSchema }, + // }, + // }, + // }), }, }, }, diff --git a/shared/api/products/productsOpenApi.ts b/shared/api/products/productsOpenApi.ts index 825bf2590..0a0305749 100644 --- a/shared/api/products/productsOpenApi.ts +++ b/shared/api/products/productsOpenApi.ts @@ -14,9 +14,7 @@ export const productOps = { responses: { "200": { description: "200 OK", - content: { - "application/json": { schema: APIProductSchema }, - }, + content: { "application/json": { schema: APIProductSchema } }, }, }, }, diff --git a/shared/enums/ErrCode.ts b/shared/enums/ErrCode.ts index 6056b4f55..96bace738 100644 --- a/shared/enums/ErrCode.ts +++ b/shared/enums/ErrCode.ts @@ -76,21 +76,10 @@ export const ErrCode = { MultipleCustomersFound: "multiple_customers_found", InvalidUpdateCustomerParams: "invalid_update_customer_params", - // Product - InvalidProduct: "invalid_product", - ProductNotFound: "product_not_found", - ProductHasCustomers: "product_has_customers", - ProductHasNoPrices: "product_has_no_prices", - ProductHasDifferentRecurringIntervals: - "product_has_different_recurring_intervals", + // Stripe errors...? CreateStripeProductFailed: "create_stripe_product_failed", DeleteStripeProductFailed: "delete_stripe_product_failed", CreateStripeSubscriptionFailed: "create_stripe_subscription_failed", - UpdateCusProductFailed: "update_customer_product_failed", - DefaultProductNotAllowedPrice: "default_product_not_allowed_price", - UpgradeFailed: "upgrade_failed", - ProductAlreadyExists: "product_already_exists", - ProductHasRewardPrograms: "product_has_reward_programs", // Cus Product CusProductNotFound: "cus_product_not_found", diff --git a/shared/openapi.yaml b/shared/openapi.yaml index 17e82320a..c2c2f12dc 100644 --- a/shared/openapi.yaml +++ b/shared/openapi.yaml @@ -22,10 +22,9 @@ paths: id: type: string minLength: 1 - pattern: ^[a-z0-9]+$ + pattern: ^[a-zA-Z0-9_-]+$ name: type: string - minLength: 1 is_add_on: default: false type: boolean @@ -33,7 +32,6 @@ paths: default: false type: boolean version: - default: 1 type: number group: default: "" @@ -397,6 +395,24 @@ components: additionalProperties: false - type: "null" additionalProperties: false + AutumnError: + description: An error that occurred in the API + type: object + properties: + message: + type: string + code: + type: string + env: + type: string + enum: + - sandbox + - live + required: + - message + - code + - env + additionalProperties: false securitySchemes: secretKey: type: http diff --git a/shared/utils/productUtils.ts b/shared/utils/productUtils.ts index 9f38f1a97..8c286594a 100644 --- a/shared/utils/productUtils.ts +++ b/shared/utils/productUtils.ts @@ -1,5 +1,5 @@ import { Decimal } from "decimal.js"; -import { type ProductItem, type ProductV2 } from "../index.js"; +import type { ProductItem, ProductV2 } from "../index.js"; import { isFeaturePriceItem, isPriceItem,