diff --git a/server/shell/g5.sh b/server/shell/g5.sh index f083cf29f..11b1c5c1f 100755 --- a/server/shell/g5.sh +++ b/server/shell/g5.sh @@ -8,16 +8,16 @@ if [[ "$1" == *"setup"* ]]; then MOCHA_PARALLEL=true $MOCHA_SETUP fi -$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ - 'tests/advanced/coupons/*.ts' \ - 'tests/attach/updateQuantity/*.ts' \ - 'tests/advanced/referrals/*.ts' \ - 'tests/advanced/referrals/paid/*.ts' \ - 'tests/advanced/rollovers/*.ts' \ - 'tests/advanced/customInterval/*.ts' +# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ +# 'tests/advanced/coupons/*.ts' \ +# 'tests/attach/updateQuantity/*.ts' \ +# 'tests/advanced/referrals/*.ts' \ +# 'tests/advanced/referrals/paid/*.ts' \ +# 'tests/advanced/rollovers/*.ts' \ +# 'tests/advanced/customInterval/*.ts' -$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \ - 'tests/advanced/usageLimit/*.ts' +# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \ +# 'tests/advanced/usageLimit/*.ts' $MOCHA_CMD 'tests/advanced/usage/*.ts' diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts index ce71b2672..8ca9df6b4 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts @@ -10,6 +10,7 @@ import { } from "@autumn/shared"; import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { orgToCurrency } from "@/internal/orgs/orgUtils.js"; import { PriceService } from "@/internal/products/prices/PriceService.js"; import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js"; import { billingIntervalToStripe } from "../stripePriceUtils.js"; @@ -105,7 +106,7 @@ export const createStripePrepaid = async ({ stripePrice = await stripeCli.prices.create({ ...productData, unit_amount_decimal: unitAmountDecimalStr, - currency: org.default_currency!, + currency: orgToCurrency({ org }), }); config.stripe_product_id = stripePrice.product as string; @@ -128,7 +129,7 @@ export const createStripePrepaid = async ({ stripePrice = await stripeCli.prices.create({ ...productData, - currency: org.default_currency!, + currency: orgToCurrency({ org }), ...priceAmountData, recurring: { ...(recurringData as any), diff --git a/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts b/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts index f2907fc2a..40b6b7393 100644 --- a/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts +++ b/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts @@ -1,9 +1,14 @@ +import type { + EntitlementWithFeature, + FeatureOptions, + Organization, + Price, + UsagePriceConfig, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import { orgToCurrency } from "@/internal/orgs/orgUtils.js"; import { getPriceForOverage } from "@/internal/products/prices/priceUtils.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; -import { FeatureOptions, Organization, UsagePriceConfig } from "@autumn/shared"; -import { EntitlementWithFeature } from "@autumn/shared"; -import { Price } from "@autumn/shared"; -import { Decimal } from "decimal.js"; export const priceToOneOffAndTiered = ({ price, @@ -19,8 +24,8 @@ export const priceToOneOffAndTiered = ({ stripeProductId: string; }) => { const config = price.config as UsagePriceConfig; - let quantity = options?.quantity!; - let overage = new Decimal(quantity).mul(config.billing_units!).toNumber(); + const quantity = options?.quantity!; + const overage = new Decimal(quantity).mul(config.billing_units!).toNumber(); // let overage = quantity * config.billing_units! - relatedEnt.allowance!; // if (overage <= 0) { @@ -39,7 +44,7 @@ export const priceToOneOffAndTiered = ({ ? config.stripe_product_id : stripeProductId, unit_amount: Number(amount.toFixed(2)) * 100, - currency: org.default_currency, + currency: orgToCurrency({ org }), }, quantity: 1, @@ -58,11 +63,11 @@ export const priceToUsageInAdvance = ({ isCheckout: boolean; }) => { const config = price.config as UsagePriceConfig; - let optionsQuantity = options?.quantity; + const optionsQuantity = options?.quantity; let finalQuantity = optionsQuantity; // 1. If adjustable quantity is set, use that, else if quantity is undefined, adjustable is true, else false - let adjustable = notNullish(options?.adjustable_quantity) + const adjustable = notNullish(options?.adjustable_quantity) ? options!.adjustable_quantity : nullish(optionsQuantity) ? true diff --git a/server/src/external/stripe/stripePriceUtils.ts b/server/src/external/stripe/stripePriceUtils.ts index dc3002574..01d2d52c7 100644 --- a/server/src/external/stripe/stripePriceUtils.ts +++ b/server/src/external/stripe/stripePriceUtils.ts @@ -136,3 +136,21 @@ export const getPlaceholderItem = ({ quantity: 0, }; }; + +export const createEmptySubItem = ({ + recurring, + stripeProductId, +}: { + recurring: Stripe.PriceCreateParams.Recurring; + stripeProductId: string; +}) => { + return { + price_data: { + product: stripeProductId, + unit_amount: 1, + currency: "usd", + recurring, + }, + quantity: 0, + } satisfies Stripe.SubscriptionCreateParams.Item; +}; diff --git a/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts b/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts index a8f958542..1978eeda2 100644 --- a/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts @@ -1,4 +1,4 @@ -import Stripe from "stripe"; +import type Stripe from "stripe"; export const getLatestPeriodEnd = ({ sub, @@ -34,7 +34,7 @@ export const getEarliestPeriodStart = ({ }: { sub: Stripe.Subscription; }) => { - if (sub.items.data.length == 0) { + if (sub.items.data.length === 0) { return Date.now(); } @@ -43,7 +43,7 @@ export const getEarliestPeriodStart = ({ }, sub.items.data[0].current_period_start); }; export const getLatestPeriodStart = ({ sub }: { sub: Stripe.Subscription }) => { - if (sub.items.data.length == 0) { + if (sub.items.data.length === 0) { return Date.now(); } @@ -53,7 +53,7 @@ export const getLatestPeriodStart = ({ sub }: { sub: Stripe.Subscription }) => { }; export const subToPeriodStartEnd = ({ sub }: { sub?: Stripe.Subscription }) => { - if (!sub || sub.items.data.length == 0) { + if (!sub || sub.items.data.length === 0) { return { start: Date.now(), end: Date.now(), diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts index ab27e56dc..cdd63c104 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -1,6 +1,5 @@ import { ApiVersion, - AppEnv, type Customer, EntInterval, type FullCusProduct, @@ -21,7 +20,6 @@ import { submitUsageToStripe } from "../../stripeMeterUtils.js"; import { getInvoiceItemForUsage } from "../../stripePriceUtils.js"; import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js"; import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js"; -import { getAllFullCustomers } from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js"; export const handleUsagePrices = async ({ db, @@ -136,14 +134,6 @@ export const handleUsagePrices = async ({ return; } - const allFullCustomers = await getAllFullCustomers({ - db, - orgId: org.id, - env: AppEnv.Live, - }); - - console.log(`All full customers: ${allFullCustomers.length}`); - const ent = relatedCusEnt.entitlement; const resetBalancesUpdate = getResetBalancesUpdate({ diff --git a/server/src/external/webhooks/connectWebhookRouter.ts b/server/src/external/webhooks/connectWebhookRouter.ts index 755d1315b..2d2b61e56 100644 --- a/server/src/external/webhooks/connectWebhookRouter.ts +++ b/server/src/external/webhooks/connectWebhookRouter.ts @@ -77,9 +77,11 @@ export const handleConnectWebhook = async (c: Context) => { org = data.org; features = data.features; } catch { - logger.error( - `Account ID ${accountId} not linked to any org, skipping Stripe webhook`, - ); + if (process.env.NODE_ENV !== "development") { + logger.error( + `Account ID ${accountId} not linked to any org, skipping Stripe webhook`, + ); + } return c.json( { message: "Account ID not linked to any org, skipping Stripe webhook" }, 200, diff --git a/server/src/honoMiddlewares/errorSkipMiddleware.ts b/server/src/honoMiddlewares/errorSkipMiddleware.ts index 84619431b..22890ec70 100644 --- a/server/src/honoMiddlewares/errorSkipMiddleware.ts +++ b/server/src/honoMiddlewares/errorSkipMiddleware.ts @@ -77,6 +77,42 @@ const STRIPE_RULES = [ statusCode: 400, code: ErrCode.InvalidRequest, }, + { + name: "Card declined error", + match: (err: Error) => + err instanceof Stripe.errors.StripeError && + err.message.includes("Your card was declined."), + statusCode: 400, + code: ErrCode.InvalidRequest, + }, + { + name: "Cannot delete org with production customers", + match: (err: Error) => + err instanceof Stripe.errors.StripeError && + err.message.includes("Cannot delete org with production mode customers"), + statusCode: 400, + code: ErrCode.InvalidRequest, + }, + { + name: "Webhook endpoint limit reached", + match: (err: Error) => + err instanceof Stripe.errors.StripeError && + err.message.includes( + "You have reached the maximum of 16 test webhook endpoints", + ), + statusCode: 400, + code: ErrCode.InvalidRequest, + }, + { + name: "Invalid URL scheme error", + match: (err: Error) => + err instanceof Stripe.errors.StripeError && + err.message.includes( + "Invalid URL: An explicit scheme (such as https) must be provided", + ), + statusCode: 400, + code: ErrCode.InvalidRequest, + }, ] as const; /** Zod-specific error handling rules */ diff --git a/server/src/internal/analytics/runActionHandlerTask.ts b/server/src/internal/analytics/runActionHandlerTask.ts index 498103bc2..4ca650227 100644 --- a/server/src/internal/analytics/runActionHandlerTask.ts +++ b/server/src/internal/analytics/runActionHandlerTask.ts @@ -1,10 +1,9 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; +import type { Job, Queue } from "bullmq"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { JobName } from "@/queue/JobName.js"; import { getLock, releaseLock } from "@/queue/lockUtils.js"; -import { Queue } from "bullmq"; -import { Job } from "bullmq"; -import { handleProductsUpdated } from "./handlers/handleProductsUpdated.js"; import { handleCustomerCreated } from "./handlers/handleCustomerCreated.js"; +import { handleProductsUpdated } from "./handlers/handleProductsUpdated.js"; export const runActionHandlerTask = async ({ queue, @@ -19,12 +18,12 @@ export const runActionHandlerTask = async ({ db: DrizzleCli; useBackup: boolean; }) => { - let payload = job.data; - let internalCustomerId = payload.internalCustomerId; - let lockKey = `action:${internalCustomerId}`; + const payload = job.data; + const internalCustomerId = payload.internalCustomerId; + const lockKey = `action:${internalCustomerId}`; try { - let lock = await getLock({ queue, job, lockKey, useBackup }); + const lock = await getLock({ queue, job, lockKey, useBackup }); if (!lock) return; switch (job.name) { @@ -44,11 +43,7 @@ export const runActionHandlerTask = async ({ break; } } catch (error: any) { - logger.error("Error processing action handler job:", { - // jobName: job.name, - // payload, - message: error.message, - }); + logger.error(`Error processing action handler job: ${error.message}`); } finally { await releaseLock({ lockKey, useBackup }); } diff --git a/server/src/internal/customers/add-product/handleCreateCheckout.ts b/server/src/internal/customers/add-product/handleCreateCheckout.ts index 06fb5e9d3..b4d85f5d6 100644 --- a/server/src/internal/customers/add-product/handleCreateCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateCheckout.ts @@ -9,6 +9,7 @@ import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js"; import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js"; +import { orgToCurrency } from "@/internal/orgs/orgUtils.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"; @@ -124,12 +125,13 @@ export const handleCreateCheckout = async ({ line_items: items, subscription_data: subscriptionData, mode: isRecurring ? "subscription" : "payment", - currency: org.default_currency, + currency: orgToCurrency({ org }), success_url: successUrl || toSuccessUrl({ org, env: customer.env }), allow_promotion_codes: allowPromotionCodes, invoice_creation: !isRecurring ? { enabled: true } : undefined, saved_payment_method_options: { payment_method_save: "enabled" }, + ...rewardData, ...(attachParams.checkoutSessionParams || {}), metadata: { @@ -166,11 +168,7 @@ export const handleCreateCheckout = async ({ ); } catch (error: any) { const msg = error.message; - if ( - msg && - msg.includes("No valid payment method types") && - !paymentMethodSet - ) { + if (msg?.includes("No valid payment method types") && !paymentMethodSet) { checkout = await stripeCli.checkout.sessions.create({ ...sessionParams, payment_method_types: ["card"], diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index fc7a8df52..b34cd10be 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -18,6 +18,7 @@ import { attachToInvoiceResponse, insertInvoiceFromAttach, } from "@/internal/invoices/invoiceUtils.js"; +import { orgToCurrency } from "@/internal/orgs/orgUtils.js"; import { priceToProduct } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js"; import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; @@ -99,7 +100,7 @@ export const handleOneOffFunction = async ({ description, price_data: { unit_amount: new Decimal(amount).mul(100).round().toNumber(), - currency: org.default_currency, + currency: orgToCurrency({ org }), product: price.config?.stripe_product_id || product?.processor?.id!, }, }; @@ -135,7 +136,7 @@ export const handleOneOffFunction = async ({ let stripeInvoice = await stripeCli.invoices.create({ customer: customer.processor.id!, auto_advance: false, - currency: org.default_currency!, + currency: orgToCurrency({ org }), discounts: rewards ? rewards.map((r) => ({ coupon: r.id })) : undefined, collection_method: attachParams.invoiceOnly ? "send_invoice" : undefined, days_until_due: attachParams.invoiceOnly ? 30 : undefined, diff --git a/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts index 0b367d078..200407bbf 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts @@ -6,6 +6,7 @@ import { import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { getOptions } from "@/internal/api/entitled/checkUtils.js"; import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; +import { orgToCurrency } from "@/internal/orgs/orgUtils.js"; import { mapToProductItems } from "@/internal/products/productV2Utils.js"; import type { AttachParams } from "../../cusProducts/AttachParams.js"; import { @@ -66,7 +67,7 @@ export const getDowngradeProductPreview = async ({ // console.log("Items:", items); return { - currency: attachParams.org.default_currency, + currency: orgToCurrency({ org: attachParams.org }), due_next_cycle: { line_items: items, due_at: nextCycleAt, diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetStripeAccount.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetStripeAccount.ts index 1c9630769..8c23e7589 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleGetStripeAccount.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetStripeAccount.ts @@ -5,15 +5,21 @@ import { isStripeConnected } from "../../orgUtils.js"; export const handleGetStripeAccount = createRoute({ handler: async (c) => { const ctx = c.get("ctx"); - const { org, env } = ctx; + const { org, env, logger } = ctx; if (!isStripeConnected({ org, env })) { return c.json(null); } - const stripeCli = createStripeCli({ org, env }); - const account_details = await stripeCli.accounts.retrieve(); - - return c.json(account_details); + try { + const stripeCli = createStripeCli({ org, env }); + const accountDetails = await stripeCli.accounts.retrieve(); + return c.json(accountDetails); + } catch (error) { + logger.warn( + `Failed to retrieve Stripe account for org ${org.slug}, ${error}`, + ); + return c.json(null); + } }, }); diff --git a/server/src/internal/orgs/orgUtils.ts b/server/src/internal/orgs/orgUtils.ts index 0fcbd8c66..c3fb933c0 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -306,3 +306,7 @@ export const unsetOrgStripeKeys = async ({ }, }); }; + +export const orgToCurrency = ({ org }: { org: Organization }) => { + return org.default_currency || "usd"; +}; diff --git a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts index 0ef4a5fe8..d5d1ba8e3 100644 --- a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts +++ b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts @@ -3,6 +3,7 @@ import { member, type Organization, organizations, + RecaseError, user as userTable, } from "@autumn/shared"; import { generateId } from "better-auth"; @@ -82,19 +83,25 @@ export const handleCreatePlatformOrg = createRoute({ ) .limit(1); - const orgExists = OrgService.getBySlug({ + const orgExists = await OrgService.getBySlug({ db, slug: orgSlug, }); - let org: Organization; + if (orgExists && existingMembership.length === 0) { + throw new RecaseError({ + message: `Organization with slug '${orgSlug}' already exists but ${user_email} is not a member`, + }); + } + + let org: Organization & { master?: Organization | null }; if (existingMembership.length === 0) { // Create new organization const orgId = generateId(); console.log(`Creating new organization: ${orgId} (${orgSlug})`); - [org] = await db + const [insertedOrg] = await db .insert(organizations) .values({ id: orgId, @@ -107,6 +114,8 @@ export const handleCreatePlatformOrg = createRoute({ }) .returning(); + org = { ...insertedOrg, master: masterOrg }; + // Create membership await db.insert(member).values({ id: generateId(), @@ -121,7 +130,7 @@ export const handleCreatePlatformOrg = createRoute({ logger.info(`Created new organization: ${org.id} (${orgSlug})`); } else { - org = existingMembership[0].organizations; + org = { ...existingMembership[0].organizations, master: masterOrg }; logger.info(`Found existing organization: ${org.id} (${orgSlug})`); } diff --git a/server/src/internal/platform/platformBeta/platformBetaRouter.ts b/server/src/internal/platform/platformBeta/platformBetaRouter.ts index d2cf54de0..79a23c095 100644 --- a/server/src/internal/platform/platformBeta/platformBetaRouter.ts +++ b/server/src/internal/platform/platformBeta/platformBetaRouter.ts @@ -78,6 +78,10 @@ platformBetaRouter.post( "/organization/stripe", ...handleUpdateOrganizationStripe, ); +platformBetaRouter.post( + "/organizations/stripe", + ...handleUpdateOrganizationStripe, +); platformBetaRouter.get("/users", ...listPlatformUsers); diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index ddadd9050..1f816bca6 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -90,7 +90,7 @@ export const constructProduct = ({ is_add_on: productData.is_add_on, is_default: productData.is_default, version: productData.version || 1, - group: productData.group, + group: productData.group || "", env, internal_id: generateId("prod"), @@ -447,6 +447,7 @@ export const copyProduct = async ({ db, product: { ...ProductSchema.parse(newProduct), + // group: newProduct.group || "", version: 1, }, }); diff --git a/server/src/internal/products/productUtils/compareProductUtils.ts b/server/src/internal/products/productUtils/compareProductUtils.ts index 27e773b78..407187337 100644 --- a/server/src/internal/products/productUtils/compareProductUtils.ts +++ b/server/src/internal/products/productUtils/compareProductUtils.ts @@ -142,6 +142,7 @@ export const productsAreSame = ({ item1: item, item2: similarItem!, features, + logDifferences: false, }); if (!same) { diff --git a/server/src/utils/importUtils/addProductFromSubs.ts b/server/src/utils/importUtils/addProductFromSubs.ts index c3f571f94..4d93186d6 100644 --- a/server/src/utils/importUtils/addProductFromSubs.ts +++ b/server/src/utils/importUtils/addProductFromSubs.ts @@ -88,6 +88,8 @@ export const addProductFromSubs = async ({ sub, }); + const disableFreeTrial = true; + const newCusProduct = await createFullCusProduct({ db, attachParams: { @@ -108,10 +110,12 @@ export const addProductFromSubs = async ({ entityId: entity?.id, isCustom: isCustom, }, + + disableFreeTrial, logger, trialEndsAt: trialEndsAt || undefined, subscriptionIds: sub ? [sub.id] : [], - anchorToUnix: anchorToUnix || end, + anchorToUnix: anchorToUnix || end * 1000, subscriptionStatus: sub?.status ? (stripeToAutumnSubStatus(sub?.status) as CusProductStatus) diff --git a/server/src/utils/routerUtils.ts b/server/src/utils/routerUtils.ts index 27c3b4b40..bdc7ad1cd 100644 --- a/server/src/utils/routerUtils.ts +++ b/server/src/utils/routerUtils.ts @@ -1,7 +1,6 @@ import { ErrCode } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import qs from "qs"; -import Stripe from "stripe"; import { ZodAny, ZodError, ZodObject } from "zod"; import { withSpan as withSpanTracer } from "@/internal/analytics/tracer/spanUtils.js"; import RecaseError, { @@ -9,6 +8,7 @@ import RecaseError, { handleRequestError, } from "./errorUtils.js"; import type { ExtendedRequest } from "./models/Request.js"; +import { handleExpressErrorSkip } from "./routerUtils/expressErrorSkip.js"; /** * Parses query parameters with proper type coercion for validation @@ -263,78 +263,25 @@ export const routeHandler = async ({ }); } } catch (error) { - if (error instanceof RecaseError) { - if (error.code === ErrCode.EntityNotFound) { - req.logger.warn(`${error.message}, org: ${req.org?.slug || req.orgId}`); - return res.status(404).json({ - message: error.message, - code: error.code, - }); - } - } - - const originalUrl = req.originalUrl; - if (error instanceof Stripe.errors.StripeError) { - if ( - originalUrl.includes("/exchange") && - error.message.includes("Invalid API Key provided") - ) { - req.logger.warn(`Exchange router, invalid API Key provided`); - - return res.status(400).json({ - message: error.message, - code: ErrCode.InvalidRequest, - }); - } - - if ( - error.message.includes("not a valid email address") || - error.message.includes("email: Invalid input") - ) { - req.logger.warn(`Invalid email address`); - return res.status(400).json({ - message: error.message, - code: ErrCode.InvalidRequest, - }); - } - - if ( - originalUrl.includes("/billing_portal") && - error.message.includes("Provide a configuration or create your default") - ) { - req.logger.warn(`Billing portal config error, org: ${req.org?.slug}`); - return res.status(404).json({ - message: error.message, - code: ErrCode.InvalidRequest, - }); - } - - if ( - originalUrl.includes("/billing_portal") && - error.message.includes( - "Invalid URL: An explicit scheme (such as https)", - ) - ) { - req.logger.warn( - `Billing portal return_url error, org: ${req.org?.slug}, return_url: ${req.body.return_url}`, - ); - return res.status(400).json({ - message: error.message, - code: ErrCode.InvalidRequest, - }); - } + // Check if error should be skipped (logged as warning) + const skipResponse = handleExpressErrorSkip({ error, req, res }); + if (skipResponse) { + return skipResponse; } + // Handle Zod errors on /attach endpoint + let handledError = error; if (error instanceof ZodError && req.originalUrl.includes("/attach")) { - error = new RecaseError({ + handledError = new RecaseError({ message: formatZodError(error as any), code: ErrCode.InvalidInputs, statusCode: StatusCodes.BAD_REQUEST, }); } + // Handle all other errors handleRequestError({ - error, + error: handledError, req, res, action, diff --git a/server/src/utils/routerUtils/expressErrorSkip.ts b/server/src/utils/routerUtils/expressErrorSkip.ts new file mode 100644 index 000000000..1ef435ae5 --- /dev/null +++ b/server/src/utils/routerUtils/expressErrorSkip.ts @@ -0,0 +1,152 @@ +import { ErrCode } from "@autumn/shared"; +import Stripe from "stripe"; +import RecaseError from "../errorUtils.js"; + +type ExpressRequest = { + originalUrl: string; + logger: { + warn: (message: string) => void; + }; + org?: { + slug?: string; + }; + orgId?: string; + body?: any; +}; + +type ExpressResponse = { + status: (code: number) => { + json: (data: any) => any; + }; +}; + +/** + * Checks if an error should be handled as a warning instead of an error. + * Returns response object if handled, null otherwise. + */ +export const handleExpressErrorSkip = ({ + error, + req, + res, +}: { + error: any; + req: ExpressRequest; + res: ExpressResponse; +}) => { + const originalUrl = req.originalUrl; + + // Handle RecaseError with EntityNotFound code + if (error instanceof RecaseError) { + if (error.code === ErrCode.EntityNotFound) { + req.logger.warn(`${error.message}, org: ${req.org?.slug || req.orgId}`); + return res.status(404).json({ + message: error.message, + code: error.code, + }); + } + } + + // Handle Stripe errors + if (error instanceof Stripe.errors.StripeError) { + // Exchange router invalid API key + if ( + originalUrl.includes("/exchange") && + error.message.includes("Invalid API Key provided") + ) { + req.logger.warn("Exchange router, invalid API Key provided"); + return res.status(400).json({ + message: error.message, + code: ErrCode.InvalidRequest, + }); + } + + // Invalid email address + if ( + error.message.includes("not a valid email address") || + error.message.includes("email: Invalid input") + ) { + req.logger.warn("Invalid email address"); + return res.status(400).json({ + message: error.message, + code: ErrCode.InvalidRequest, + }); + } + + // Billing portal config error + if ( + originalUrl.includes("/billing_portal") && + error.message.includes("Provide a configuration or create your default") + ) { + req.logger.warn(`Billing portal config error, org: ${req.org?.slug}`); + return res.status(404).json({ + message: error.message, + code: ErrCode.InvalidRequest, + }); + } + + // Billing portal return_url error + if ( + originalUrl.includes("/billing_portal") && + error.message.includes("Invalid URL: An explicit scheme (such as https)") + ) { + req.logger.warn( + `Billing portal return_url error, org: ${req.org?.slug}, return_url: ${req.body?.return_url}`, + ); + return res.status(400).json({ + message: error.message, + code: ErrCode.InvalidRequest, + }); + } + + // Card declined error + if (error.message.includes("Your card was declined.")) { + req.logger.warn(`Card declined error, org: ${req.org?.slug}`); + return res.status(400).json({ + message: error.message, + code: ErrCode.InvalidRequest, + }); + } + + // Cannot delete org with production customers + if ( + error.message.includes("Cannot delete org with production mode customers") + ) { + req.logger.warn( + `Cannot delete org with production customers, org: ${req.org?.slug}`, + ); + return res.status(400).json({ + message: error.message, + code: ErrCode.InvalidRequest, + }); + } + + // Webhook endpoint limit reached + if ( + error.message.includes( + "You have reached the maximum of 16 test webhook endpoints", + ) + ) { + req.logger.warn(`Webhook endpoint limit reached, org: ${req.org?.slug}`); + return res.status(400).json({ + message: error.message, + code: ErrCode.InvalidRequest, + }); + } + + // Generic invalid URL scheme error + if ( + error.message.includes( + "Invalid URL: An explicit scheme (such as https) must be provided", + ) + ) { + req.logger.warn(`Invalid URL scheme error, org: ${req.org?.slug}`); + return res.status(400).json({ + message: error.message, + code: ErrCode.InvalidRequest, + }); + } + } + + // No skip case matched + return null; +}; diff --git a/server/src/utils/scriptUtils/logUtils/logSubItems.ts b/server/src/utils/scriptUtils/logUtils/logSubItems.ts index dbbb1cc9f..a44c3330d 100644 --- a/server/src/utils/scriptUtils/logUtils/logSubItems.ts +++ b/server/src/utils/scriptUtils/logUtils/logSubItems.ts @@ -5,9 +5,13 @@ import { subItemToAutumnInterval } from "@/external/stripe/utils.js"; export const logSubItems = ({ sub, subItems, + withPriceId = false, + withItemId = false, }: { sub?: Stripe.Subscription; subItems?: Stripe.SubscriptionItem[]; + withPriceId?: boolean; + withItemId?: boolean; }) => { const finalSubItems = subItems || sub!.items.data; for (const item of finalSubItems) { @@ -24,7 +28,7 @@ export const logSubItems = ({ const price = atmnPrice; const subInterval = subItemToAutumnInterval(item); console.log( - `${price} ${item.price.currency}${item.quantity !== 1 ? ` x ${item.quantity}` : ""} / ${subInterval?.intervalCount} ${subInterval?.interval}`, + `${price} ${item.price.currency}${item.quantity !== 1 ? ` x ${item.quantity}` : ""} / ${subInterval?.intervalCount} ${subInterval?.interval} ${withPriceId ? `(${item.price.id})` : ""} ${withItemId ? `(${item.id})` : ""}`, ); } } diff --git a/server/tests/attach/basic/basic5.test.ts b/server/tests/attach/basic/basic5.test.ts index bdba4246e..2601c5cc7 100644 --- a/server/tests/attach/basic/basic5.test.ts +++ b/server/tests/attach/basic/basic5.test.ts @@ -48,7 +48,7 @@ describe(`${chalk.yellowBright("basic5: Testing cancel through Stripe at period await timeout(5000); }); - test.skip("should have pro product active, and canceled_at != null, and free scheduled", async () => { + test("should have pro product active, and canceled_at != null, and free scheduled", async () => { const cusRes: any = await AutumnCli.getCustomer(customerId); compareMainProduct({ sent: products.pro, diff --git a/server/tests/attach/basic/basic9.test.ts b/server/tests/attach/basic/basic9.test.ts index 369a7a45f..7c2485d7c 100644 --- a/server/tests/attach/basic/basic9.test.ts +++ b/server/tests/attach/basic/basic9.test.ts @@ -2,9 +2,9 @@ import { beforeAll, describe, test } from "bun:test"; import chalk from "chalk"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { features, products } from "tests/global.js"; +import { compareMainProduct } from "tests/utils/compare.js"; import { timeout } from "tests/utils/genUtils.js"; import { completeCheckoutForm } from "tests/utils/stripeUtils.js"; -import { compareMainProduct } from "tests/utils/compare.js"; import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; diff --git a/server/tests/attach/entities/entity5.test.ts b/server/tests/attach/entities/entity5.test.ts index b7579306c..1b0781513 100644 --- a/server/tests/attach/entities/entity5.test.ts +++ b/server/tests/attach/entities/entity5.test.ts @@ -135,7 +135,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing downgrade entity pro const entity2Res = await autumn.entities.get(customerId, entity2.id); const premiumProd = entity2Res.products.find( - (p: any) => p.id == premium.id, + (p: any) => p.id === premium.id, ); expect(premiumProd).toBeDefined(); expect(premiumProd.status).toBe(CusProductStatus.Active); diff --git a/server/tests/contUse/entities/entity5.ts b/server/tests/contUse/entities/entity5.ts index 28f117a5b..46beea1fb 100644 --- a/server/tests/contUse/entities/entity5.ts +++ b/server/tests/contUse/entities/entity5.ts @@ -130,7 +130,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing create entity payme it("should try to create entities and fail", async () => { await expectAutumnError({ - errMessage: "(Stripe Error) Your card was declined.", + errMessage: "Your card was declined.", func: async () => { await autumn.entities.create(customerId, [ { diff --git a/shared/models/productV2Models/productItemModels/productItemModels.ts b/shared/models/productV2Models/productItemModels/productItemModels.ts index ebf11be9b..6f108e7ac 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -65,17 +65,22 @@ export const ProductItemSchema = z.object({ feature_id: z.string().nullish(), feature_type: z.nativeEnum(ProductItemFeatureType).nullish(), included_usage: z.union([z.number(), z.literal(Infinite)]).nullish(), - interval: z.preprocess((val) => { - if (val === "") { - throw new Error("Interval cannot be empty."); - } - return val; - }, z.enum(ProductItemInterval).nullish()), + interval: z + .enum(ProductItemInterval, { + error: (issue) => { + if (issue.input === "") { + return { + message: "Interval cannot be empty.", + }; + } + }, + }) + .nullish(), interval_count: z.number().nullish(), entity_feature_id: z.string().nullish(), // Price config - usage_model: z.nativeEnum(UsageModel).nullish(), + usage_model: z.enum(UsageModel).nullish(), price: z.number().nullish(), tiers: z.array(PriceTierSchema).nullish(), billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units) diff --git a/shared/utils/cusProductUtils/productIdToCusProduct.ts b/shared/utils/cusProductUtils/productIdToCusProduct.ts index 9dd72cf13..6b13fed59 100644 --- a/shared/utils/cusProductUtils/productIdToCusProduct.ts +++ b/shared/utils/cusProductUtils/productIdToCusProduct.ts @@ -1,5 +1,5 @@ -import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; -import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import type { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; +import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; import { nullish } from "../utils.js"; export const productToCusProduct = ({ @@ -18,19 +18,44 @@ export const productToCusProduct = ({ inStatuses?: CusProductStatus[]; }) => { if (cusProductId) { - return cusProducts.find((cusProduct) => cusProduct.id === cusProductId); + return cusProducts.find((cusProduct) => { + const cusProductIdMatch = cusProduct.id === cusProductId; + const versionMatch = version + ? cusProduct.product.version === version + : true; + + const prodIdMatch = cusProduct.product.id === productId; + + const entityMatch = internalEntityId + ? cusProduct.internal_entity_id === internalEntityId + : nullish(cusProduct.internal_entity_id); + + const statusMatch = inStatuses + ? inStatuses.includes(cusProduct.status) + : true; + + return ( + cusProductIdMatch && + versionMatch && + prodIdMatch && + entityMatch && + statusMatch + ); + }); } return cusProducts.find((cusProduct) => { - let prodIdMatch = cusProduct.product.id === productId; + const versionMatch = version + ? cusProduct.product.version === version + : true; - let entityMatch = internalEntityId + const prodIdMatch = cusProduct.product.id === productId; + + const entityMatch = internalEntityId ? cusProduct.internal_entity_id === internalEntityId : nullish(cusProduct.internal_entity_id); - let versionMatch = version ? cusProduct.product.version === version : true; - - let statusMatch = inStatuses + const statusMatch = inStatuses ? inStatuses.includes(cusProduct.status) : true; diff --git a/shared/utils/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 1b2cca375..6c122aa27 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -1,8 +1,8 @@ import { FeatureType } from "../models/featureModels/featureEnums.js"; import type { Feature } from "../models/featureModels/featureModels.js"; import { Infinite } from "../models/productModels/productEnums.js"; -import type { - ProductItem, +import { + type ProductItem, ProductItemInterval, } from "../models/productV2Models/productItemModels/productItemModels.js"; import { @@ -58,11 +58,19 @@ export const getIntervalString = ({ interval: ProductItemInterval | null | undefined; intervalCount?: number | null; }) => { + let intervalStr: string = interval || ""; + + if (interval === ProductItemInterval.SemiAnnual) { + intervalStr = "half year"; + } + + console.log("intervalStr", intervalStr); + if (!interval) return ""; if (intervalCount === 1) { - return `per ${interval}`; + return `per ${intervalStr}`; } - return `per ${intervalCount} ${interval}s`; + return `per ${intervalCount} ${intervalStr}s`; }; export const getFeatureItemDisplay = ({ diff --git a/shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.ts b/shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.ts index 6518d3fa2..ff03d289d 100644 --- a/shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.ts +++ b/shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.ts @@ -105,9 +105,11 @@ const tiersAreSame = ( export const featureItemsAreSame = ({ item1, item2, + logDifferences = false, }: { item1: FeatureItem; item2: FeatureItem; + logDifferences?: boolean; }) => { const checks = { feature_id: { @@ -135,15 +137,22 @@ export const featureItemsAreSame = ({ item1.reset_usage_when_enabled == item2.reset_usage_when_enabled, message: `Reset usage when enabled different: ${item1.reset_usage_when_enabled} !== ${item2.reset_usage_when_enabled}`, }, - config: { - condition: JSON.stringify(item1.config) === JSON.stringify(item2.config), - message: `Config different: ${JSON.stringify(item1.config)} !== ${JSON.stringify(item2.config)}`, + rollover_config: { + condition: rolloversAreSame({ + rollover1: item1.config?.rollover || undefined, + rollover2: item2.config?.rollover || undefined, + }), + message: `Rollover config different: ${JSON.stringify(item1.config?.rollover)} !== ${JSON.stringify(item2.config?.rollover)}`, }, + // config: { + // condition: JSON.stringify(item1.config) === JSON.stringify(item2.config), + // message: `Config different: ${JSON.stringify(item1.config)} !== ${JSON.stringify(item2.config)}`, + // }, }; const same = Object.values(checks).every((d) => d.condition); - if (!same) { + if (!same && logDifferences) { console.log( "Feature items different:", Object.values(checks) @@ -158,16 +167,18 @@ export const featureItemsAreSame = ({ export const priceItemsAreSame = ({ item1, item2, + logDifferences = false, }: { item1: PriceItem; item2: PriceItem; + logDifferences?: boolean; }) => { const same = item1.price === item2.price && item1.interval == item2.interval && (item1.interval_count || 1) == (item2.interval_count || 1); - if (!same) { + if (!same && logDifferences) { console.log(`Price items different: ${item1.price}`); } @@ -210,9 +221,11 @@ const rolloversAreSame = ({ export const featurePriceItemsAreSame = ({ item1, item2, + logDifferences = false, }: { item1: FeaturePriceItem; item2: FeaturePriceItem; + logDifferences?: boolean; }) => { // console.log("Item 1 config:", item1.config); // console.log("Item 2 config:", item2.config); @@ -297,7 +310,7 @@ export const featurePriceItemsAreSame = ({ const pricesChanged = Object.values(pricesSame).some((d) => !d.condition); - if (!same) { + if (!same && logDifferences) { console.log( "Feature price items different:", Object.values(entsSame) @@ -319,10 +332,12 @@ export const itemsAreSame = ({ item1, item2, features, + logDifferences = false, }: { item1: ProductItem; item2: ProductItem; features?: Feature[]; + logDifferences?: boolean; }) => { // 1. If feature item let same = false; @@ -339,6 +354,7 @@ export const itemsAreSame = ({ same = featureItemsAreSame({ item1: item1 as FeatureItem, item2: item2 as FeatureItem, + logDifferences, }); pricesChanged = false; @@ -356,6 +372,7 @@ export const itemsAreSame = ({ featurePriceItemsAreSame({ item1: item1 as FeaturePriceItem, item2: item2 as FeaturePriceItem, + logDifferences, }); same = same_; @@ -377,6 +394,7 @@ export const itemsAreSame = ({ same = priceItemsAreSame({ item1: item1 as PriceItem, item2: item2 as PriceItem, + logDifferences, }); if (!same) { pricesChanged = true; diff --git a/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts b/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts index c513bfe46..22baf8322 100644 --- a/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts +++ b/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts @@ -190,6 +190,7 @@ export const productsAreSame = ({ item1: item, item2: similarItem, features, + logDifferences: false, }); if (!same) { diff --git a/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts b/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts index a8a559599..d3b8fae6f 100644 --- a/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts +++ b/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts @@ -1,3 +1,4 @@ +import type { FixedPriceConfig } from "@models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; import type { ProductItem, ProductItemInterval, @@ -14,6 +15,8 @@ export function productV2ToBasePrice({ product }: { product: ProductV2 }): { interval: ProductItemInterval; intervalCount: number; item: ProductItem; + config: FixedPriceConfig; + priceId: string; } | null { const item = product.items.find((x) => isPriceItem(x)); @@ -23,6 +26,8 @@ export function productV2ToBasePrice({ product }: { product: ProductV2 }): { interval: (item.interval as unknown as ProductItemInterval) || null, intervalCount: item.interval_count || 1, item: item, + config: item.price_config as FixedPriceConfig, + priceId: item.price_id || "", }; } diff --git a/vite/src/components/autumn/PlanCardPreview.tsx b/vite/src/components/autumn/PlanCardPreview.tsx index 72a536206..282ac801d 100644 --- a/vite/src/components/autumn/PlanCardPreview.tsx +++ b/vite/src/components/autumn/PlanCardPreview.tsx @@ -5,7 +5,6 @@ import { type ProductV2, productV2ToFeatureItems, } from "@autumn/shared"; -import { useState } from "react"; import { Button } from "@/components/v2/buttons/Button"; import { Card, CardContent, CardHeader } from "@/components/v2/cards/Card"; import { Separator } from "@/components/v2/separator"; diff --git a/vite/src/components/autumn/pricing-table-preview.tsx b/vite/src/components/autumn/pricing-table-preview.tsx index 439d774c1..1a62a7b7d 100644 --- a/vite/src/components/autumn/pricing-table-preview.tsx +++ b/vite/src/components/autumn/pricing-table-preview.tsx @@ -5,6 +5,7 @@ import { useCustomer } from "autumn-js/react"; import { useState } from "react"; import { useOrg } from "@/hooks/common/useOrg"; import OnboardingCheckoutDialog from "@/views/onboarding3/OnboardingCheckoutDialog"; +import { useOnboardingStore } from "@/views/onboarding3/store/useOnboardingStore"; import { PlanCardPreview } from "./PlanCardPreview"; interface PricingTableProps { @@ -23,6 +24,9 @@ export default function PricingTablePreview({ refreshInterval: 0, }, }); + const setLastUsedProductId = useOnboardingStore( + (state) => state.setLastUsedProductId, + ); const [loadingProductId, setLoadingProductId] = useState(null); if (!products || products.length === 0) { @@ -30,10 +34,9 @@ export default function PricingTablePreview({ } const handleSubscribe = async (product: ProductV2) => { - // Check if Stripe is connected (works for both OAuth and API key) - if (!org || org.stripe_connection === "default") { - setConnectStripeOpen(true); - return; + // Track the product ID that was clicked + if (product.id) { + setLastUsedProductId(product.id); } if (product.id) { @@ -84,7 +87,7 @@ export default function PricingTablePreview({ } else if (productCount === 2) { return "flex flex-col gap-6 max-w-2xl mx-auto px-4 sm:grid sm:grid-cols-2 sm:flex-none"; // Vertical on mobile, 2 columns on sm+ } else { - return "flex flex-col gap-6 max-w-7xl mx-auto px-4 sm:grid md:grid-cols-2 xl:grid-cols-3 sm:flex-none"; // Vertical on mobile, 2 columns on sm+, 3 on lg+ + return "flex flex-col gap-6 max-w-7xl mx-auto px-4 sm:grid lg:grid-cols-2 2xl:grid-cols-3 sm:flex-none"; // Vertical on mobile, 2 columns on sm+, 3 on lg+ } }; diff --git a/vite/src/components/general/AdminHover.tsx b/vite/src/components/general/AdminHover.tsx index 296a66ece..2921d3161 100644 --- a/vite/src/components/general/AdminHover.tsx +++ b/vite/src/components/general/AdminHover.tsx @@ -1,16 +1,13 @@ -import React, { - useState, - forwardRef, - cloneElement, - isValidElement, -} from "react"; -import { Check } from "lucide-react"; -import { Tooltip, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; - -import { TooltipContent } from "../ui/tooltip"; -import { Copy } from "lucide-react"; -import { useSession } from "@/lib/auth-client"; +import { Check, Copy } from "lucide-react"; +import type React from "react"; +import { cloneElement, forwardRef, isValidElement, useState } from "react"; import { useAdmin } from "@/views/admin/hooks/useAdmin"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; export const AdminHover = forwardRef< HTMLElement, @@ -19,8 +16,9 @@ export const AdminHover = forwardRef< texts: (string | { key: string; value: string } | undefined | null)[]; hide?: boolean; asChild?: boolean; + side?: "top" | "bottom" | "left" | "right"; } ->(({ children, texts, hide = false, asChild = false }, ref) => { +>(({ children, texts, hide = false, asChild = true, side = "bottom" }, ref) => { const { isAdmin } = useAdmin(); if (!isAdmin || hide) return <>{children}; @@ -34,14 +32,12 @@ export const AdminHover = forwardRef< return ( - - {triggerChild} - + {triggerChild} {isAdmin && (
{texts.map((text: any) => { diff --git a/vite/src/components/general/modal-components/WarningBox.tsx b/vite/src/components/general/modal-components/WarningBox.tsx index 5b0c18894..81218314d 100644 --- a/vite/src/components/general/modal-components/WarningBox.tsx +++ b/vite/src/components/general/modal-components/WarningBox.tsx @@ -10,7 +10,7 @@ export const WarningBox = ({ return (
diff --git a/vite/src/views/command-bar/CommandBar.tsx b/vite/src/views/command-bar/CommandBar.tsx index 519dc6784..b5bcc2500 100644 --- a/vite/src/views/command-bar/CommandBar.tsx +++ b/vite/src/views/command-bar/CommandBar.tsx @@ -190,6 +190,18 @@ const CommandBar = () => { setOpen(true); }); + // Direct shortcut to open impersonation search (admin only) + useHotkeys( + "meta+6", + () => { + if (isAdmin) { + setOpen(true); + setCurrentPage("impersonate"); + } + }, + [isAdmin], + ); + useHotkeys( "escape", (e) => { @@ -463,17 +475,6 @@ const CommandBar = () => { return ( <> - {!showResults && ( - -
- Start typing to search... -
-
- )} - {showResults && ( <> {userResults.length > 0 && ( diff --git a/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx b/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx index 5fc574c14..2a3aaaea6 100644 --- a/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx +++ b/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx @@ -12,7 +12,7 @@ export const useCusProductQuery = () => { const { customer_id, product_id } = useParams(); const [queryStates] = useQueryStates({ version: parseAsInteger, - customer_product_id: parseAsString, + id: parseAsString, entity_id: parseAsString, }); @@ -23,7 +23,7 @@ export const useCusProductQuery = () => { productId: product_id, queryStates: { version: stableStates.version ?? undefined, - customerProductId: stableStates.customer_product_id ?? undefined, + customerProductId: stableStates.id ?? undefined, entityId: stableStates.entity_id ?? undefined, }, }); @@ -33,7 +33,7 @@ export const useCusProductQuery = () => { const fetcher = async () => { const queryParams = { version: stableStates.version, - customer_product_id: stableStates.customer_product_id, + customer_product_id: stableStates.id, entity_id: stableStates.entity_id, }; @@ -57,7 +57,7 @@ export const useCusProductQuery = () => { customer_id, product_id, stableStates.version, - stableStates.customer_product_id, + stableStates.id, stableStates.entity_id, ], queryFn: fetcher, diff --git a/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx b/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx index 78b8d6a34..a38c4c32c 100644 --- a/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx +++ b/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx @@ -111,11 +111,16 @@ export const ConfigureStripe = () => { const accountName = stripeAccount?.business_profile?.name || stripeAccount?.settings?.dashboard?.display_name; + const accountId = stripeAccount?.id; + const prefix = accountId + ? `You have connected the Stripe account ${accountId}` + : "You have your connected your Stripe account"; + if (connection === "secret_key") { return { - description: `You have connected the Stripe account ${accountId}${accountName ? ` (${accountName})` : ""} via secret key.`, // Will show dashboard link in the same line + description: `${prefix} ${accountName ? ` (${accountName})` : ""} via secret key.`, // Will show dashboard link in the same line showDisconnect: true, showConnectButtons: false, showDefaultAccountLink: true, @@ -126,9 +131,9 @@ export const ConfigureStripe = () => { const accountName = stripeAccount?.business_profile?.name || stripeAccount?.settings?.dashboard?.display_name; - const accountId = stripeAccount?.id; + return { - description: `You have connected the Stripe account ${accountId}${accountName ? ` (${accountName})` : ""} via OAuth.`, + description: `${prefix} ${accountName ? ` (${accountName})` : ""} via OAuth.`, showDisconnect: true, showConnectButtons: false, showDefaultAccountLink: false, diff --git a/vite/src/views/main-sidebar/SidebarBottom.tsx b/vite/src/views/main-sidebar/SidebarBottom.tsx index 60e6eb33b..587e2370b 100644 --- a/vite/src/views/main-sidebar/SidebarBottom.tsx +++ b/vite/src/views/main-sidebar/SidebarBottom.tsx @@ -21,13 +21,6 @@ export default function SidebarBottom() { title="Connect to Stripe" env={env} /> */} - } - title="Command Palette" - onClick={openCommandBar} - isGroup={true} - /> } diff --git a/vite/src/views/onboarding3/components/OnboardingStepRenderer.tsx b/vite/src/views/onboarding3/components/OnboardingStepRenderer.tsx index e84585142..28c80cb8d 100644 --- a/vite/src/views/onboarding3/components/OnboardingStepRenderer.tsx +++ b/vite/src/views/onboarding3/components/OnboardingStepRenderer.tsx @@ -1,6 +1,6 @@ import type { ProductItem, ProductV2 } from "@autumn/shared"; import { productV2ToFeatureItems } from "@autumn/shared"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useFeatureStore } from "@/hooks/stores/useFeatureStore"; import { useProductStore } from "@/hooks/stores/useProductStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; @@ -27,18 +27,30 @@ export const OnboardingStepRenderer = () => { // Get state from Zustand const playgroundMode = useOnboardingStore((state) => state.playgroundMode); + const setLastUsedProductId = useOnboardingStore( + (state) => state.setLastUsedProductId, + ); const feature = useFeatureStore((state) => state.feature); const product = useProductStore((s) => s.product); const setProduct = useProductStore((s) => s.setProduct); const sheetType = useSheetStore((s) => s.type); const itemId = useSheetStore((s) => s.itemId); + const [trackResponse, setTrackResponse] = useState(null); const [checkResponse, setCheckResponse] = useState(null); + const [lastUsedFeatureId, setLastUsedFeatureId] = useState< string | undefined >(undefined); + // Track product ID changes when in playground mode + useEffect(() => { + if (step === OnboardingStep.Playground && product?.id) { + setLastUsedProductId(product.id); + } + }, [product?.id, step, setLastUsedProductId]); + // Don't render overrides when on Integration step or Playground preview mode - allow the step to render normally const shouldSkipOverrides = step === OnboardingStep.Integration || diff --git a/vite/src/views/onboarding3/components/playground-step/AvailableFeatures.tsx b/vite/src/views/onboarding3/components/playground-step/AvailableFeatures.tsx index f373d950d..a46d7de05 100644 --- a/vite/src/views/onboarding3/components/playground-step/AvailableFeatures.tsx +++ b/vite/src/views/onboarding3/components/playground-step/AvailableFeatures.tsx @@ -1,6 +1,6 @@ import { FeatureType } from "@autumn/shared"; import { ArrowRightIcon } from "@phosphor-icons/react"; -import { useCustomer } from "autumn-js/react"; +import { PaywallDialog, useCustomer } from "autumn-js/react"; import { useState } from "react"; import { Button } from "@/components/v2/buttons/Button"; import { Input } from "@/components/v2/inputs/Input"; @@ -104,11 +104,11 @@ export const AvailableFeatures = ({ const featureId = customer?.features[x].id; // Check the feature access - const { data: checkResponse, error: checkError } = - await check({ - featureId: featureId, - requiredBalance: value, - }); + const { data: checkResponse, error: checkError } = check({ + featureId: featureId, + requiredBalance: value, + dialog: PaywallDialog, + }); if (!checkError && checkResponse && onCheckSuccess) { onCheckSuccess(checkResponse); @@ -118,6 +118,8 @@ export const AvailableFeatures = ({ onFeatureUsed(featureId); } + if (!checkResponse?.allowed) return; + // Track the usage const { data, error } = await track({ featureId: featureId, @@ -135,8 +137,8 @@ export const AvailableFeatures = ({ )) ) : ( - Your current plan doesn't have any features. Try purchasing a - plan in the preview first. + Your current plan doesn't have any features. Try purchasing a plan + in the preview first. )}
diff --git a/vite/src/views/onboarding3/components/playground-step/QuickStartCodeGroup.tsx b/vite/src/views/onboarding3/components/playground-step/QuickStartCodeGroup.tsx index 5867dfec3..336165de8 100644 --- a/vite/src/views/onboarding3/components/playground-step/QuickStartCodeGroup.tsx +++ b/vite/src/views/onboarding3/components/playground-step/QuickStartCodeGroup.tsx @@ -13,6 +13,7 @@ import { import { SheetSection } from "@/components/v2/sheets/InlineSheet"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useProductStore } from "@/hooks/stores/useProductStore"; +import { useOnboardingStore } from "../../store/useOnboardingStore"; import { getCodeSnippets } from "../../utils/completionStepCode"; type CodeLanguage = "react" | "nodejs" | "response"; @@ -125,13 +126,18 @@ export const QuickStartCodeGroup = ({ }) => { const { product } = useProductStore(); const { features } = useFeaturesQuery(); + const lastUsedProductId = useOnboardingStore( + (state) => state.lastUsedProductId, + ); // Use the feature that was actually used (if available), otherwise fallback to first feature const firstFeatureItem = product?.items?.find( (item: ProductItem) => item.feature_id, ); const featureId = usedFeatureId || firstFeatureItem?.feature_id || undefined; - const productId = product?.id || undefined; + + // Use lastUsedProductId (from pricing card clicks) or fallback to current product + const productId = lastUsedProductId || product?.id || undefined; // Get the actual feature name from features list const featureName = features.find((f) => f.id === featureId)?.name; @@ -152,10 +158,7 @@ export const QuickStartCodeGroup = ({ snippets={snippets.track} trackResponse={trackResponse} /> - +
diff --git a/vite/src/views/onboarding3/store/useOnboardingStore.ts b/vite/src/views/onboarding3/store/useOnboardingStore.ts index 029b92bec..ed96e3f48 100644 --- a/vite/src/views/onboarding3/store/useOnboardingStore.ts +++ b/vite/src/views/onboarding3/store/useOnboardingStore.ts @@ -11,6 +11,7 @@ interface OnboardingState { // UI state isButtonLoading: boolean; + lastUsedProductId: string | undefined; // Action handlers (set by initialization hooks) handleNext: (() => void) | null; @@ -33,6 +34,7 @@ interface OnboardingState { // Actions - UI setIsButtonLoading: (loading: boolean) => void; + setLastUsedProductId: (productId: string | undefined) => void; // Actions - Set handlers (called by initialization hooks) setHandleNext: (handler: () => void) => void; @@ -63,6 +65,7 @@ const createInitialState = () => ({ // UI isButtonLoading: false, + lastUsedProductId: undefined as string | undefined, // Action handlers (initialized by hooks) handleNext: null as (() => void) | null, @@ -84,6 +87,7 @@ export const useOnboardingStore = create((set) => ({ // UI actions setIsButtonLoading: (isButtonLoading) => set({ isButtonLoading }), + setLastUsedProductId: (lastUsedProductId) => set({ lastUsedProductId }), // Set action handlers (called by initialization hooks) setHandleNext: (handleNext) => set({ handleNext }), diff --git a/vite/src/views/onboarding3/utils/completionStepCode.ts b/vite/src/views/onboarding3/utils/completionStepCode.ts index 6ad79a1cc..03fdb84eb 100644 --- a/vite/src/views/onboarding3/utils/completionStepCode.ts +++ b/vite/src/views/onboarding3/utils/completionStepCode.ts @@ -11,10 +11,14 @@ export const getCodeSnippets = ( allowed: { react: `import { useCustomer } from 'autumn-js/react'; -const { allowed } = useCustomer(); +const { check } = useCustomer(); const handleCheckFeature = async () => { - if ( !allowed({ featureId: '${actualFeatureId}' }) ) { + const { data } = await check({ + featureId: '${actualFeatureId}', + requiredQuantity: 1 + }); + if (!data?.allowed) { alert('Feature not allowed'); } }`, @@ -24,7 +28,7 @@ const autumn = new Autumn({ apiKey: process.env.AUTUMN_API_KEY }); -const allowed = await autumn.check({ +const { data, error } = await autumn.check({ customerId: 'cust_123', featureId: '${actualFeatureId}' });`, @@ -92,21 +96,9 @@ console.log(session.checkout_url);`, track: { react: `import { useCustomer } from 'autumn-js/react'; -const { check, track } = useCustomer(); +const { track } = useCustomer(); const handleAction = async () => { - // 1. Check if user has access first - const { data } = await check({ - featureId: '${actualFeatureId}', - requiredQuantity: 1 - }); - - if (!data?.allowed) { - alert("You've reached your limit!"); - return; - } - - // 2. Track usage after successful check await track({ featureId: '${actualFeatureId}', value: 1, diff --git a/vite/src/views/products/features/components/CreateFeatureSheet.tsx b/vite/src/views/products/features/components/CreateFeatureSheet.tsx index ae65fc17f..4d2608de8 100644 --- a/vite/src/views/products/features/components/CreateFeatureSheet.tsx +++ b/vite/src/views/products/features/components/CreateFeatureSheet.tsx @@ -27,9 +27,13 @@ import { getDefaultFeature } from "../utils/defaultFeature"; function CreateFeatureSheet({ open: controlledOpen, onOpenChange: controlledOnOpenChange, + onSuccess, + isControlled = false, }: { open?: boolean; onOpenChange?: (open: boolean) => void; + onSuccess?: (featureId: string) => void; + isControlled?: boolean; } = {}) { const [loading, setLoading] = useState(false); const [internalOpen, setInternalOpen] = useState(false); @@ -55,17 +59,24 @@ function CreateFeatureSheet({ setLoading(false); } else { try { - await FeatureService.createFeature(axiosInstance, { - name: feature.name, - id: feature.id, - type: feature.type, - config: feature.config, - event_names: feature.event_names, - }); + const { data: createdFeature } = await FeatureService.createFeature( + axiosInstance, + { + name: feature.name, + id: feature.id, + type: feature.type, + config: feature.config, + event_names: feature.event_names, + }, + ); await refetch(); toast.success("Feature created successfully"); setOpen(false); + + if (onSuccess && createdFeature.id) { + onSuccess(createdFeature.id); + } } catch (error: unknown) { toast.error( getBackendErr(error as AxiosError, "Failed to create feature"), @@ -90,22 +101,18 @@ function CreateFeatureSheet({ return ( - - - + {!isControlled && ( + + + + )} - {/* - New Feature - - Configure how this feature is used in your app - - */}
diff --git a/vite/src/views/products/plan/components/ConfirmMigrationDialog.tsx b/vite/src/views/products/plan/components/ConfirmMigrationDialog.tsx index 1ece25f1e..de2a75426 100644 --- a/vite/src/views/products/plan/components/ConfirmMigrationDialog.tsx +++ b/vite/src/views/products/plan/components/ConfirmMigrationDialog.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { toast } from "sonner"; +import { WarningBox } from "@/components/general/modal-components/WarningBox"; import { Button } from "@/components/v2/buttons/Button"; import { Dialog, @@ -64,9 +65,13 @@ export const ConfirmMigrationDialog = ({

This will migrate all customers on {product.name} (version{" "} - {version}) to the latest version. Custom plans and cancelled plans - will not be migrated. + {version}) to the latest version.

+ + Features and balances will be immediately migrated. Pricing + changes will take effect from the next billing cycle. Custom plans + and cancelled plans will not be migrated. +

Type {product.id}{" "} to continue. diff --git a/vite/src/views/products/plan/components/EditPlanHeader.tsx b/vite/src/views/products/plan/components/EditPlanHeader.tsx index 9ff445ec4..e15ab7bca 100644 --- a/vite/src/views/products/plan/components/EditPlanHeader.tsx +++ b/vite/src/views/products/plan/components/EditPlanHeader.tsx @@ -141,9 +141,11 @@ export const EditPlanHeader = () => { size="sm" /> )} + }> {counts?.active || 0} +

diff --git a/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx b/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx index 5a6395cdb..1256e873e 100644 --- a/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx +++ b/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx @@ -1,5 +1,6 @@ import { formatAmount, + getIntervalString, mapToProductV3, type Organization, } from "@autumn/shared"; @@ -26,7 +27,7 @@ export const BasePriceDisplay = () => { }); const secondaryText = productV3.price?.interval - ? `per ${productV3.price.interval}` + ? `${getIntervalString({ interval: productV3.price.interval, intervalCount: productV3.price.intervalCount })}` : "once"; const priceExists = notNullish(productV3.price) && productV3.price.amount > 0; diff --git a/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx b/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx index 8fef80f90..4ac39be78 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx @@ -1,4 +1,5 @@ import { mapToProductV3 } from "@autumn/shared"; +import { AdminHover } from "@/components/general/AdminHover"; import { PlanTypeBadges } from "@/components/v2/badges/PlanTypeBadges"; import { CardHeader } from "@/components/v2/cards/Card"; import { useOrg } from "@/hooks/common/useOrg"; @@ -16,16 +17,30 @@ export const PlanCardHeader = () => { const isPlanBeingEdited = useIsEditingPlan(); const productV3 = mapToProductV3({ product }); + const adminHoverText = () => { + return [ + { + key: "Price ID", + value: productV3.price?.priceId || "N/A", + }, + { + key: "Stripe Price ID", + value: productV3.price?.config?.stripe_price_id || "N/A", + }, + ]; + }; return (
- - {product.name.length > MAX_PLAN_NAME_LENGTH - ? `${product.name.slice(0, MAX_PLAN_NAME_LENGTH)}...` - : product.name} - + + + {product.name.length > MAX_PLAN_NAME_LENGTH + ? `${product.name.slice(0, MAX_PLAN_NAME_LENGTH)}...` + : product.name} + + MAX_PLAN_NAME_LENGTH - 10} diff --git a/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx b/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx index 3192fcec9..241e6ffd6 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx @@ -52,28 +52,14 @@ export const PlanCardToolbar = ({ aria-label="Edit plan" variant="muted" disabled={editDisabled} - size="sm" - className={cn(isEditingPlan && "btn-secondary-active !opacity-100 ")} + // size="sm" + className={cn( + // "text-body", + isEditingPlan && "btn-secondary-active !opacity-100 ", + )} > - Edit Details + Plan Details - - {/* {product?.archived ? ( - - ) : ( - } - onClick={() => setDeleteOpen(true)} - aria-label="Delete plan" - variant="muted" - iconOrientation="center" - disabled={deleteDisabled} - title={deleteDisabled && deleteTooltip ? deleteTooltip : undefined} - className={cn(deleteDisabled && "opacity-50 cursor-not-allowed")} - /> - )} */}
); diff --git a/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx b/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx index fd5e9a588..a2c2fbd3f 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx @@ -4,6 +4,7 @@ import type { ProductItem } from "@autumn/shared"; import { getProductItemDisplay, productV2ToFeatureItems } from "@autumn/shared"; import { TrashIcon } from "@phosphor-icons/react"; import { useEffect, useState } from "react"; +import { AdminHover } from "@/components/general/AdminHover"; import { CopyButton } from "@/components/v2/buttons/CopyButton"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { useOrg } from "@/hooks/common/useOrg"; @@ -108,6 +109,51 @@ export const PlanFeatureRow = ({ } }; + const adminHoverText = () => { + return [ + ...(item.entitlement_id + ? [ + { + key: "Entitlement ID", + value: item.entitlement_id || "N/A", + }, + ] + : []), + ...(item.price_id + ? [ + { + key: "Price ID", + value: item.price_id || "N/A", + }, + ] + : []), + ...(item.price_config?.stripe_price_id + ? [ + { + key: "Stripe Price ID", + value: item.price_config?.stripe_price_id || "N/A", + }, + ] + : []), + ...(item.price_config?.stripe_empty_price_id + ? [ + { + key: "Stripe Empty Price ID", + value: item.price_config?.stripe_empty_price_id || "N/A", + }, + ] + : []), + ...(item.price_config?.stripe_product_id + ? [ + { + key: "Stripe Product ID", + value: item.price_config?.stripe_product_id || "N/A", + }, + ] + : []), + ]; + }; + return (
{/* Left side - Icons and text */}
-
- - - -
+ +
+ + + + + +
+

{displayText} + {" "} {display.secondary_text}

+ { const { features } = useFeaturesQuery(); const { item, setItem, isUpdate, stepState } = useProductItemContext(); const [open, setOpen] = useState(false); + const [sheetOpen, setSheetOpen] = useState(false); const itemType = getItemType(item); + const handleFeatureCreated = (featureId: string) => { + setItem({ ...item, feature_id: featureId }); + }; + return ( -
- { + setItem({ ...item, feature_id: value }); + }} + disabled={isUpdate} + > + + + + + {features + .filter((feature: Feature) => { + if (feature.archived && feature.id !== item.feature_id) + return false; + if (itemType === ProductItemType.FeaturePrice) { + return feature.type !== FeatureType.Boolean; + } + return true; + }) + .map((feature: Feature) => ( + +
+ {feature.name} + +
+
+ ))} + +
+ + {!isUpdate && item.feature_id && ( - - - {!isUpdate && item.feature_id && ( - - )} -
+ )} +
+ + + ); }; diff --git a/vite/src/views/products/product/product-item/product-item-config/FeatureItemConfig.tsx b/vite/src/views/products/product/product-item/product-item-config/FeatureItemConfig.tsx index 95cb0407e..b0fbdbb48 100644 --- a/vite/src/views/products/product/product-item/product-item-config/FeatureItemConfig.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/FeatureItemConfig.tsx @@ -1,59 +1,52 @@ +import React from "react"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { isFeatureItem, isFeaturePriceItem } from "@/utils/product/getItemType"; import { useProductItemContext } from "../ProductItemContext"; -import { BillingInterval, FeatureUsageType, Infinite } from "@autumn/shared"; +import FeaturePrice from "./components/feature-price/FeaturePrice"; import { SelectCycle } from "./components/feature-price/SelectBillingCycle"; import { IncludedUsage } from "./components/IncludedUsage"; import { SelectResetCycle } from "./components/SelectResetCycle"; -import FeaturePrice from "./components/feature-price/FeaturePrice"; -import { isFeatureItem, isFeaturePriceItem } from "@/utils/product/getItemType"; -import React from "react"; - -import { notNullish } from "@/utils/genUtils"; -import { - getFeature, - getFeatureUsageType, -} from "@/utils/product/entitlementUtils"; -import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; export const FeatureConfig = () => { const { features } = useFeaturesQuery(); const { item, setItem } = useProductItemContext(); - if (!item.feature_id) return null; + if (!item?.feature_id) return null; const isFeaturePrice = isFeaturePriceItem(item); const isFeature = isFeatureItem(item); - const handleAddUsagePrice = () => { - const newIncludedUsage = - item.included_usage == Infinite ? 0 : item.included_usage; + // const handleAddUsagePrice = () => { + // const newIncludedUsage = + // item.included_usage == Infinite ? 0 : item.included_usage; - let newInterval = item.interval; - if ( - notNullish(item.interval) && - !Object.values(BillingInterval).includes(item.interval) - ) { - newInterval = BillingInterval.Month; - } + // let newInterval = item.interval; + // if ( + // notNullish(item.interval) && + // !Object.values(BillingInterval).includes(item.interval) + // ) { + // newInterval = BillingInterval.Month; + // } - setItem({ - ...item, - included_usage: newIncludedUsage, - tiers: [{ to: Infinite, amount: 0 }], - interval: newInterval, - }); - }; + // setItem({ + // ...item, + // included_usage: newIncludedUsage, + // tiers: [{ to: Infinite, amount: 0 }], + // interval: newInterval, + // }); + // }; - const price = - getFeatureUsageType({ item, features }) == FeatureUsageType.Continuous - ? "10" - : "1"; + // const price = + // getFeatureUsageType({ item, features }) == FeatureUsageType.Continuous + // ? "10" + // : "1"; - const feature = getFeature(item?.feature_id, features); + // const feature = getFeature(item?.feature_id, features); return ( <> {isFeature && ( -
+
diff --git a/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx b/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx index 394921277..f15cdccd3 100644 --- a/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx @@ -9,20 +9,6 @@ import FieldLabel from "@/components/general/modal-components/FieldLabel"; import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip"; import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton"; import { Input } from "@/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Button } from "@/components/v2/buttons/Button"; -import { formatIntervalText } from "@/utils/formatUtils/formatTextUtils"; import { isFeaturePriceItem } from "@/utils/product/getItemType"; import { itemIsUnlimited } from "@/utils/product/productItemUtils"; import { useProductItemContext } from "../../ProductItemContext"; @@ -123,94 +109,6 @@ export const IncludedUsage = () => {
- -
- - Usage Reset & Billing Interval - - - How often usage counts reset for this feature. Choose "no reset" - for items that don't expire. - - - -
- setIntervalCount(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSaveCustomInterval(); - } - if (e.key === "Escape") { - setOpen(false); - } - }} - /> - -
- - - - -
-
); }; diff --git a/vite/vite.config.ts b/vite/vite.config.ts index 7f2531185..5262161fb 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -25,8 +25,8 @@ export default defineConfig({ "@radix/tabs": "@radix-ui/react-tabs", "@radix/tooltip": "@radix-ui/react-tooltip", }, - // Preserve symlinks for workspace dependencies - preserveSymlinks: true, + // // Preserve symlinks for workspace dependencies + // preserveSymlinks: true, }, optimizeDeps: { // Exclude workspace dependencies from pre-bundling to avoid cache issues