From 1dd0ee9e8a6f485e00ec5d401da3832ae2ca4937 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Sat, 16 Aug 2025 12:51:55 -0700 Subject: [PATCH] fix: default toggle --- server/shell/g1.sh | 2 - .../customers/cusProducts/cusProductUtils.ts | 1005 ++++++++--------- .../customers/cusUtils/createNewCustomer.ts | 349 +++--- .../handlers/handleCreateCustomer.ts | 1 - .../src/internal/products/ProductService.ts | 5 +- .../products/handlers/handleCreateProduct.ts | 75 +- .../handleUpdateProduct.ts | 71 +- .../updateProductDetails.ts | 424 +++---- .../products/internalProductRouter.ts | 41 +- server/src/internal/products/productUtils.ts | 25 + .../products/productUtils/classifyProduct.ts | 32 +- .../utils/scriptUtils/createTestProducts.ts | 6 +- server/src/utils/scriptUtils/initCustomer.ts | 113 +- .../defaultTrial/defaultTrial0.test.ts | 152 --- .../defaultTrial/defaultTrial1.test.ts | 184 +-- .../defaultTrial/defaultTrial2.test.ts | 185 +-- .../defaultTrial/defaultTrial3.test.ts | 214 ++-- .../defaultTrial/defaultTrialBefore.test.ts | 57 + server/tests/attach/basic/basic2.ts | 7 +- .../expectUtils/expectProductAttached.ts | 14 +- .../utils/testAttachUtils/trialAttachUtils.ts | 390 +++---- .../freeTrialModels/freeTrialModels.ts | 2 +- vite/src/views/customers/CustomersView.tsx | 10 +- .../onboarding2/model-pricing/EditProduct.tsx | 18 +- vite/src/views/products/CreateProduct.tsx | 14 +- vite/src/views/products/ProductsView.tsx | 20 +- .../views/products/product/ProductProps.tsx | 507 +++------ .../views/products/product/ProductSidebar.tsx | 5 +- .../products/product/ProductVersions.tsx | 10 +- .../product/free-trial/CreateFreeTrial.tsx | 2 +- .../product/free-trial/FreeTrialConfig.tsx | 4 +- .../products/product/hooks/useProductData.tsx | 11 +- .../product-sidebar/ToggleDefaultProduct.tsx | 211 ++++ 33 files changed, 2169 insertions(+), 1997 deletions(-) delete mode 100644 server/tests/advanced/defaultTrial/defaultTrial0.test.ts create mode 100644 server/tests/advanced/defaultTrial/defaultTrialBefore.test.ts create mode 100644 vite/src/views/products/product/product-sidebar/ToggleDefaultProduct.tsx diff --git a/server/shell/g1.sh b/server/shell/g1.sh index 83e778557..9bf27833d 100755 --- a/server/shell/g1.sh +++ b/server/shell/g1.sh @@ -19,5 +19,3 @@ $MOCHA_CMD \ 'tests/attach/checkout/*.ts' \ 'tests/attach/entities/*.ts' \ 'tests/attach/free/*.ts'\ - -# 'tests/attach/basic/basic2.ts' \ \ No newline at end of file diff --git a/server/src/internal/customers/cusProducts/cusProductUtils.ts b/server/src/internal/customers/cusProducts/cusProductUtils.ts index dafc4282f..3b076cfd6 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils.ts @@ -1,23 +1,23 @@ import { - APIVersion, - AppEnv, - AttachScenario, - CusProductResponseSchema, - CusProductStatus, - Customer, - Entity, - FixedPriceConfig, - FullCusProduct, - FullCustomer, - Organization, - PriceType, - Subscription, - TierInfinite, - UsagePriceConfig, + APIVersion, + AppEnv, + AttachScenario, + CusProductResponseSchema, + CusProductStatus, + Customer, + Entity, + FixedPriceConfig, + FullCusProduct, + FullCustomer, + Organization, + PriceType, + Subscription, + TierInfinite, + UsagePriceConfig, } from "@autumn/shared"; import { - getPriceOptions, - getUsageTier, + getPriceOptions, + getUsageTier, } from "@/internal/products/prices/priceUtils.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { CusProductService, RELEVANT_STATUSES } from "./CusProductService.js"; @@ -25,9 +25,9 @@ import { createStripeCli } from "@/external/stripe/utils.js"; import { createFullCusProduct } from "../add-product/createFullCusProduct.js"; import Stripe from "stripe"; import { - deleteScheduledIds, - getStripeSubs, - subIsPrematurelyCanceled, + deleteScheduledIds, + getStripeSubs, + subIsPrematurelyCanceled, } from "@/external/stripe/stripeSubUtils.js"; import { getRelatedCusEnt } from "./cusPrices/cusPriceUtils.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; @@ -39,603 +39,592 @@ import { ExtendedRequest } from "@/utils/models/Request.js"; import { cusProductToPrices } from "./cusProductUtils/convertCusProduct.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; import { - isDefaultTrial, - isDefaultTrialFullProduct, + isDefaultTrial, + isDefaultTrialFullProduct, } from "@/internal/products/productUtils/classifyProduct.js"; import { initStripeCusAndProducts } from "../handlers/handleCreateCustomer.js"; import { handleAddProduct } from "../attach/attachFunctions/addProductFlow/handleAddProduct.js"; import { newCusToAttachParams } from "../attach/attachUtils/attachParams/convertToParams.js"; export const isActiveStatus = (status: CusProductStatus) => { - return ( - status === CusProductStatus.Active || - status === CusProductStatus.PastDue - ); + return ( + status === CusProductStatus.Active || status === CusProductStatus.PastDue + ); }; // 1. Cancel cusProductSubscriptions export const cancelCusProductSubscriptions = async ({ - cusProduct, - org, - env, - excludeIds, - expireImmediately = true, - logger, - prorate = true, + cusProduct, + org, + env, + excludeIds, + expireImmediately = true, + logger, + prorate = true, }: { - cusProduct: FullCusProduct; - org: Organization; - env: AppEnv; - excludeIds?: string[]; - expireImmediately?: boolean; - logger: any; - prorate?: boolean; + cusProduct: FullCusProduct; + org: Organization; + env: AppEnv; + excludeIds?: string[]; + expireImmediately?: boolean; + logger: any; + prorate?: boolean; }) => { - // 1. Cancel all subscriptions - const stripeCli = createStripeCli({ - org: org, - env: env, - }); + // 1. Cancel all subscriptions + const stripeCli = createStripeCli({ + org: org, + env: env, + }); - let latestSubEnd: number | undefined; - if (cusProduct.subscription_ids && cusProduct.subscription_ids.length > 0) { - let stripeSubs = await getStripeSubs({ - stripeCli, - subIds: cusProduct.subscription_ids, - }); + let latestSubEnd: number | undefined; + if (cusProduct.subscription_ids && cusProduct.subscription_ids.length > 0) { + let stripeSubs = await getStripeSubs({ + stripeCli, + subIds: cusProduct.subscription_ids, + }); - latestSubEnd = stripeSubs?.[0]?.current_period_end; - } + latestSubEnd = stripeSubs?.[0]?.current_period_end; + } - const cancelStripeSub = async (subId: string) => { - if (excludeIds && excludeIds.includes(subId)) { - return; - } + const cancelStripeSub = async (subId: string) => { + if (excludeIds && excludeIds.includes(subId)) { + return; + } - try { - if (expireImmediately) { - await stripeCli.subscriptions.cancel(subId, { - prorate: prorate, - }); - } else { - await stripeCli.subscriptions.update(subId, { - cancel_at: latestSubEnd || undefined, - cancel_at_period_end: latestSubEnd ? undefined : true, - }); - } + try { + if (expireImmediately) { + await stripeCli.subscriptions.cancel(subId, { + prorate: prorate, + }); + } else { + await stripeCli.subscriptions.update(subId, { + cancel_at: latestSubEnd || undefined, + cancel_at_period_end: latestSubEnd ? undefined : true, + }); + } - logger.info( - `Cancelled stripe subscription ${subId}, org: ${org.slug}` - ); - } catch (error: any) { - if (error.code != "resource_missing") { - console.log( - `Error canceling stripe subscription ${error.code}: ${error.message}` - ); - } // else subscription probably already cancelled - } - }; + logger.info(`Cancelled stripe subscription ${subId}, org: ${org.slug}`); + } catch (error: any) { + if (error.code != "resource_missing") { + console.log( + `Error canceling stripe subscription ${error.code}: ${error.message}` + ); + } // else subscription probably already cancelled + } + }; - if (cusProduct.subscription_ids && cusProduct.subscription_ids.length > 0) { - const batchCancel = []; - for (const subId of cusProduct.subscription_ids) { - batchCancel.push(cancelStripeSub(subId)); - } - await Promise.all(batchCancel); - return true; - } + if (cusProduct.subscription_ids && cusProduct.subscription_ids.length > 0) { + const batchCancel = []; + for (const subId of cusProduct.subscription_ids) { + batchCancel.push(cancelStripeSub(subId)); + } + await Promise.all(batchCancel); + return true; + } - return false; + return false; }; export const activateDefaultProduct = async ({ - req, - productGroup, - fullCus, - curCusProduct, + req, + productGroup, + fullCus, + curCusProduct, }: { - req: ExtendedRequest; - productGroup: string; - fullCus: FullCustomer; - curCusProduct?: FullCusProduct; + req: ExtendedRequest; + productGroup: string; + fullCus: FullCustomer; + curCusProduct?: FullCusProduct; }) => { - const { db, org, env, logger } = req; - // 1. Expire current product - const defaultProducts = await ProductService.listDefault({ - db, - orgId: org.id, - env, - }); + const { db, org, env, logger } = req; + // 1. Expire current product + const defaultProducts = await ProductService.listDefault({ + db, + orgId: org.id, + env, + }); - // Look for a paid default trial first, then fall back to free default - let defaultProd = defaultProducts.find( - (p) => - p.group === productGroup && - isDefaultTrialFullProduct({ product: p }) - ); + // Look for a paid default trial first, then fall back to free default + let defaultProd = defaultProducts.find( + (p) => p.group === productGroup && isDefaultTrialFullProduct({ product: p }) + ); - let defaultableProducts = { - free: defaultProducts.filter( - (p) => p.group === productGroup && isFreeProduct(p.prices) - ), - paid: defaultProducts.filter( - (p) => - p.group === productGroup && - isDefaultTrialFullProduct({ product: p }) - ), - }; + let defaultableProducts = { + free: defaultProducts.filter( + (p) => p.group === productGroup && isFreeProduct(p.prices) + ), + paid: defaultProducts.filter( + (p) => + p.group === productGroup && isDefaultTrialFullProduct({ product: p }) + ), + }; - console.log("Found defaultable products:", { - free: defaultableProducts.free.map((p) => p.name), - paid: defaultableProducts.paid.map((p) => p.name), - }); + // console.log("Found defaultable products:", { + // free: defaultableProducts.free.map((p) => p.name), + // paid: defaultableProducts.paid.map((p) => p.name), + // }); - if (defaultableProducts.paid.length > 0) { - defaultProd = defaultableProducts.paid[0]; - } else if (defaultableProducts.free.length > 0) { - defaultProd = defaultableProducts.free[0]; - } else { - return false; - } + if (defaultableProducts.paid.length > 0) { + defaultProd = defaultableProducts.paid[0]; + } else if (defaultableProducts.free.length > 0) { + defaultProd = defaultableProducts.free[0]; + } else { + return false; + } - if (curCusProduct?.internal_product_id == defaultProd.internal_id) { - return false; - } + if (curCusProduct?.internal_product_id == defaultProd.internal_id) { + return false; + } - const stripeCli = createStripeCli({ org, env }); - let defaultIsFree = isFreeProduct(defaultProd.prices); - let isDefaultTrial = isDefaultTrialFullProduct({ product: defaultProd }); + const stripeCli = createStripeCli({ org, env }); + let defaultIsFree = isFreeProduct(defaultProd.prices); + let isDefaultTrial = isDefaultTrialFullProduct({ product: defaultProd }); - // Initialize Stripe customer and products if needed (for paid non-trial products) - if (!defaultIsFree && !isDefaultTrial) { - await initStripeCusAndProducts({ - db, - org, - env, - customer: fullCus, - products: [defaultProd], - logger, - }); - } + // Initialize Stripe customer and products if needed (for paid non-trial products) + if (!defaultIsFree && !isDefaultTrial) { + await initStripeCusAndProducts({ + db, + org, + env, + customer: fullCus, + products: [defaultProd], + logger, + }); + } - // If the default product is not a paid trial, add it to the customer - // This is so you don't get two free trials - if (!isDefaultTrial) { - // Check if the default product already exists to prevent duplicates - const existingDefaultProduct = fullCus.customer_products.find( - (cp) => cp.product.internal_id === defaultProd!.internal_id && - (cp.status === CusProductStatus.Active || cp.status === CusProductStatus.PastDue || cp.status === CusProductStatus.Trialing) - ); - - if (existingDefaultProduct) { - logger.info(`Default product ${defaultProd!.name} already exists for customer`); - return false; - } + if (!isDefaultTrial) { + const existingDefaultProduct = fullCus.customer_products.find( + (cp) => + cp.product.internal_id === defaultProd!.internal_id && + (cp.status === CusProductStatus.Active || + cp.status === CusProductStatus.PastDue || + cp.status === CusProductStatus.Trialing) + ); - await handleAddProduct({ - req, - attachParams: newCusToAttachParams({ - req, - newCus: fullCus, - products: [defaultProd], - stripeCli, - }), - }); + if (existingDefaultProduct) { + logger.info( + `Default product ${defaultProd!.name} already exists for customer` + ); + return false; + } - // await createFullCusProduct({ - // db, - // attachParams: { - // org, - // customer, - // product: defaultProd, - // prices: defaultProd.prices, - // entitlements: defaultProd.entitlements, - // freeTrial: defaultProd.free_trial || null, - // optionsList: [], - // entities: [], - // features: [], - // replaceables: [], - // }, - // scenario: AttachScenario.New, - // logger, - // }); + await handleAddProduct({ + req, + attachParams: newCusToAttachParams({ + req, + newCus: fullCus, + products: [defaultProd], + stripeCli, + }), + }); - // console.log(` ✅ activated default product: ${defaultProd.group}`); - return true; - } else if (isDefaultTrial && defaultableProducts.free.length > 0) { - defaultProd = defaultableProducts.free[0]; + // await createFullCusProduct({ + // db, + // attachParams: { + // org, + // customer, + // product: defaultProd, + // prices: defaultProd.prices, + // entitlements: defaultProd.entitlements, + // freeTrial: defaultProd.free_trial || null, + // optionsList: [], + // entities: [], + // features: [], + // replaceables: [], + // }, + // scenario: AttachScenario.New, + // logger, + // }); - // Check if the free default product already exists to prevent duplicates - const existingFreeProduct = fullCus.customer_products.find( - (cp) => cp.product.internal_id === defaultProd!.internal_id && - (cp.status === CusProductStatus.Active || cp.status === CusProductStatus.PastDue) - ); - - if (existingFreeProduct) { - logger.info(`Free default product ${defaultProd!.name} already exists for customer`); - return false; - } + // console.log(` ✅ activated default product: ${defaultProd.group}`); + return true; + } else if (isDefaultTrial && defaultableProducts.free.length > 0) { + defaultProd = defaultableProducts.free[0]; - await handleAddProduct({ - req, - attachParams: newCusToAttachParams({ - req, - newCus: fullCus, - products: [defaultProd], - stripeCli, - }), - }); + // Check if the free default product already exists to prevent duplicates + const existingFreeProduct = fullCus.customer_products.find( + (cp) => + cp.product.internal_id === defaultProd!.internal_id && + (cp.status === CusProductStatus.Active || + cp.status === CusProductStatus.PastDue) + ); - return true; - }; + if (existingFreeProduct) { + logger.info( + `Free default product ${defaultProd!.name} already exists for customer` + ); + return false; + } + + await handleAddProduct({ + req, + attachParams: newCusToAttachParams({ + req, + newCus: fullCus, + products: [defaultProd], + stripeCli, + }), + }); + + return true; + } }; export const expireAndActivate = async ({ - req, - cusProduct, - fullCus, + req, + cusProduct, + fullCus, }: { - req: ExtendedRequest; - cusProduct: FullCusProduct; - fullCus: FullCustomer; + req: ExtendedRequest; + cusProduct: FullCusProduct; + fullCus: FullCustomer; }) => { - const { db, org, env, logger } = req; - // 1. Expire current product - await CusProductService.update({ - db, - cusProductId: cusProduct.id, - updates: { status: CusProductStatus.Expired, ended_at: Date.now() }, - }); + const { db, org, env, logger } = req; + // 1. Expire current product + await CusProductService.update({ + db, + cusProductId: cusProduct.id, + updates: { status: CusProductStatus.Expired, ended_at: Date.now() }, + }); - // Check if it's one time product - let prices = cusProductToPrices({ cusProduct }); - let product = cusProduct.product; - const isOneOffOrAddOn = product.is_add_on || isOneOff(prices); + // Check if it's one time product + let prices = cusProductToPrices({ cusProduct }); + let product = cusProduct.product; + const isOneOffOrAddOn = product.is_add_on || isOneOff(prices); - if (isOneOffOrAddOn || notNullish(cusProduct.internal_entity_id)) { - return; - } + if (isOneOffOrAddOn || notNullish(cusProduct.internal_entity_id)) { + return; + } - await activateDefaultProduct({ - req, - productGroup: cusProduct.product.group, - fullCus, - }); + await activateDefaultProduct({ + req, + productGroup: cusProduct.product.group, + fullCus, + }); }; export const activateFutureProduct = async ({ - req, - cusProduct, - subscription, + req, + cusProduct, + subscription, }: { - req: ExtendedRequest; - cusProduct: FullCusProduct; - subscription: Stripe.Subscription; + req: ExtendedRequest; + cusProduct: FullCusProduct; + subscription: Stripe.Subscription; }) => { - const { db, org, env, logger } = req; - const stripeCli = createStripeCli({ - org, - env, - }); + const { db, org, env, logger } = req; + const stripeCli = createStripeCli({ + org, + env, + }); - let cusProducts = await CusProductService.list({ - db, - internalCustomerId: cusProduct.internal_customer_id, - inStatuses: [CusProductStatus.Scheduled], - }); + let cusProducts = await CusProductService.list({ + db, + internalCustomerId: cusProduct.internal_customer_id, + inStatuses: [CusProductStatus.Scheduled], + }); - let { curScheduledProduct: futureProduct } = getExistingCusProducts({ - product: cusProduct.product, - cusProducts, - internalEntityId: cusProduct.internal_entity_id, - }); + let { curScheduledProduct: futureProduct } = getExistingCusProducts({ + product: cusProduct.product, + cusProducts, + internalEntityId: cusProduct.internal_entity_id, + }); - if (!futureProduct) { - return false; - } + if (!futureProduct) { + return false; + } - if (subIsPrematurelyCanceled(subscription)) { - console.log( - " 🔔 Subscription prematurely canceled, deleting scheduled products" - ); + if (subIsPrematurelyCanceled(subscription)) { + console.log( + " 🔔 Subscription prematurely canceled, deleting scheduled products" + ); - await deleteScheduledIds({ - stripeCli, - scheduledIds: futureProduct.scheduled_ids || [], - }); - await CusProductService.delete({ - db, - cusProductId: futureProduct.id, - }); - return false; - } else { - await CusProductService.update({ - db, - cusProductId: futureProduct.id, - updates: { status: CusProductStatus.Active }, - }); + await deleteScheduledIds({ + stripeCli, + scheduledIds: futureProduct.scheduled_ids || [], + }); + await CusProductService.delete({ + db, + cusProductId: futureProduct.id, + }); + return false; + } else { + await CusProductService.update({ + db, + cusProductId: futureProduct.id, + updates: { status: CusProductStatus.Active }, + }); - await addProductsUpdatedWebhookTask({ - req, - internalCustomerId: cusProduct.internal_customer_id, - org, - env, - customerId: null, - scenario: AttachScenario.New, - cusProduct: futureProduct, - logger, - }); + await addProductsUpdatedWebhookTask({ + req, + internalCustomerId: cusProduct.internal_customer_id, + org, + env, + customerId: null, + scenario: AttachScenario.New, + cusProduct: futureProduct, + logger, + }); - return true; - } + return true; + } }; // GET CUS ENTS FROM CUS PRODUCTS export const processFullCusProduct = ({ - cusProduct, - subs, - org, - entities = [], - apiVersion, + cusProduct, + subs, + org, + entities = [], + apiVersion, }: { - cusProduct: FullCusProduct; - org: Organization; - subs?: (Stripe.Subscription | Subscription)[]; - entities?: Entity[]; - apiVersion: number; + cusProduct: FullCusProduct; + org: Organization; + subs?: (Stripe.Subscription | Subscription)[]; + entities?: Entity[]; + apiVersion: number; }) => { - // Process prices + // Process prices - const prices = cusProduct.customer_prices.map((cp) => { - let price = cp.price; + const prices = cusProduct.customer_prices.map((cp) => { + let price = cp.price; - if (price.config?.type == PriceType.Fixed) { - let config = price.config as FixedPriceConfig; - return { - amount: config.amount, - interval: config.interval, - }; - } else { - let config = price.config as UsagePriceConfig; - let priceOptions = getPriceOptions(price, cusProduct.options); - let usageTier = getUsageTier(price, priceOptions?.quantity!); - let cusEnt = getRelatedCusEnt({ - cusPrice: cp, - cusEnts: cusProduct.customer_entitlements, - }); + if (price.config?.type == PriceType.Fixed) { + let config = price.config as FixedPriceConfig; + return { + amount: config.amount, + interval: config.interval, + }; + } else { + let config = price.config as UsagePriceConfig; + let priceOptions = getPriceOptions(price, cusProduct.options); + let usageTier = getUsageTier(price, priceOptions?.quantity!); + let cusEnt = getRelatedCusEnt({ + cusPrice: cp, + cusEnts: cusProduct.customer_entitlements, + }); - let ent = cusEnt?.entitlement; + let ent = cusEnt?.entitlement; - let singleTier = - ent?.allowance == 0 && config.usage_tiers.length == 1; + let singleTier = ent?.allowance == 0 && config.usage_tiers.length == 1; - if (singleTier) { - return { - amount: usageTier.amount, - interval: config.interval, - quantity: priceOptions?.quantity, - }; - } else { - // Add allowance to tiers - let allowance = ent?.allowance; - let tiers; + if (singleTier) { + return { + amount: usageTier.amount, + interval: config.interval, + quantity: priceOptions?.quantity, + }; + } else { + // Add allowance to tiers + let allowance = ent?.allowance; + let tiers; - if (notNullish(allowance) && allowance! > 0) { - tiers = [ - { - to: allowance, - amount: 0, - }, - ...config.usage_tiers.map((tier) => { - let isLastTier = - tier.to == -1 || tier.to == TierInfinite; - return { - to: isLastTier - ? tier.to - : Number(tier.to) + allowance!, - amount: tier.amount, - }; - }), - ]; - } else { - tiers = config.usage_tiers.map((tier) => { - let isLastTier = - tier.to == -1 || tier.to == TierInfinite; - return { - to: isLastTier - ? tier.to - : Number(tier.to) + allowance!, - amount: tier.amount, - }; - }); - } + if (notNullish(allowance) && allowance! > 0) { + tiers = [ + { + to: allowance, + amount: 0, + }, + ...config.usage_tiers.map((tier) => { + let isLastTier = tier.to == -1 || tier.to == TierInfinite; + return { + to: isLastTier ? tier.to : Number(tier.to) + allowance!, + amount: tier.amount, + }; + }), + ]; + } else { + tiers = config.usage_tiers.map((tier) => { + let isLastTier = tier.to == -1 || tier.to == TierInfinite; + return { + to: isLastTier ? tier.to : Number(tier.to) + allowance!, + amount: tier.amount, + }; + }); + } - return { - tiers: tiers, - name: "", - quantity: priceOptions?.quantity, - }; - } - } - }); + return { + tiers: tiers, + name: "", + quantity: priceOptions?.quantity, + }; + } + } + }); - const trialing = - cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now(); + const trialing = + cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now(); - const subIds = cusProduct.subscription_ids; - let stripeSubData = {}; + const subIds = cusProduct.subscription_ids; + let stripeSubData = {}; - if ( - subIds && - subIds.length > 0 && - org.config.api_version >= BREAK_API_VERSION - ) { - let baseSub = subs?.find( - (s) => - s.id == subIds[0] || (s as Subscription).stripe_id == subIds[0] - ); - stripeSubData = { - current_period_end: baseSub?.current_period_end - ? baseSub.current_period_end * 1000 - : null, - current_period_start: baseSub?.current_period_start - ? baseSub.current_period_start * 1000 - : null, - }; - } + if ( + subIds && + subIds.length > 0 && + org.config.api_version >= BREAK_API_VERSION + ) { + let baseSub = subs?.find( + (s) => s.id == subIds[0] || (s as Subscription).stripe_id == subIds[0] + ); + stripeSubData = { + current_period_end: baseSub?.current_period_end + ? baseSub.current_period_end * 1000 + : null, + current_period_start: baseSub?.current_period_start + ? baseSub.current_period_start * 1000 + : null, + }; + } - if (!subIds && trialing) { - stripeSubData = { - current_period_start: cusProduct.starts_at, - current_period_end: cusProduct.trial_ends_at, - }; - } + if (!subIds && trialing) { + stripeSubData = { + current_period_start: cusProduct.starts_at, + current_period_end: cusProduct.trial_ends_at, + }; + } - if (apiVersion >= APIVersion.v1_1) { - if ((!subIds || subIds.length == 0) && trialing) { - stripeSubData = { - current_period_start: cusProduct.starts_at, - current_period_end: cusProduct.trial_ends_at, - }; - } + if (apiVersion >= APIVersion.v1_1) { + if ((!subIds || subIds.length == 0) && trialing) { + stripeSubData = { + current_period_start: cusProduct.starts_at, + current_period_end: cusProduct.trial_ends_at, + }; + } - return CusProductResponseSchema.parse({ - id: cusProduct.product.id, - name: cusProduct.product.name, - group: cusProduct.product.group || null, - status: trialing ? CusProductStatus.Trialing : cusProduct.status, - // created_at: cusProduct.created_at, - canceled_at: cusProduct.canceled_at, - is_default: cusProduct.product.is_default || false, - is_add_on: cusProduct.product.is_add_on || false, + return CusProductResponseSchema.parse({ + id: cusProduct.product.id, + name: cusProduct.product.name, + group: cusProduct.product.group || null, + status: trialing ? CusProductStatus.Trialing : cusProduct.status, + // created_at: cusProduct.created_at, + canceled_at: cusProduct.canceled_at, + is_default: cusProduct.product.is_default || false, + is_add_on: cusProduct.product.is_add_on || false, - stripe_subscription_ids: cusProduct.subscription_ids || [], - started_at: cusProduct.starts_at, - // entity_id: cusProduct.entity_id, - entity_id: cusProduct.internal_entity_id - ? entities?.find( - (e) => e.internal_id == cusProduct.internal_entity_id - )?.id - : cusProduct.entity_id || undefined, + stripe_subscription_ids: cusProduct.subscription_ids || [], + started_at: cusProduct.starts_at, + // entity_id: cusProduct.entity_id, + entity_id: cusProduct.internal_entity_id + ? entities?.find((e) => e.internal_id == cusProduct.internal_entity_id) + ?.id + : cusProduct.entity_id || undefined, - ...stripeSubData, - }); - } else { - let cusProductResponse = { - id: cusProduct.product.id, - name: cusProduct.product.name, - group: cusProduct.product.group, - status: trialing ? CusProductStatus.Trialing : cusProduct.status, - created_at: cusProduct.created_at, - canceled_at: cusProduct.canceled_at, - processor: { - type: cusProduct.processor?.type, - subscription_id: cusProduct.processor?.subscription_id || null, - }, - subscription_ids: cusProduct.subscription_ids || [], - prices: prices, - starts_at: cusProduct.starts_at, + ...stripeSubData, + }); + } else { + let cusProductResponse = { + id: cusProduct.product.id, + name: cusProduct.product.name, + group: cusProduct.product.group, + status: trialing ? CusProductStatus.Trialing : cusProduct.status, + created_at: cusProduct.created_at, + canceled_at: cusProduct.canceled_at, + processor: { + type: cusProduct.processor?.type, + subscription_id: cusProduct.processor?.subscription_id || null, + }, + subscription_ids: cusProduct.subscription_ids || [], + prices: prices, + starts_at: cusProduct.starts_at, - ...stripeSubData, - // prices: cusProduct.customer_prices, - // entitlements: cusProduct.customer_entitlements, - }; + ...stripeSubData, + // prices: cusProduct.customer_prices, + // entitlements: cusProduct.customer_entitlements, + }; - return cusProductResponse; - } + return cusProductResponse; + } }; // GET CUSTOMER PRODUCT & ORG IN PARALLEL export const fullCusProductToProduct = (cusProduct: FullCusProduct) => { - return { - ...cusProduct.product, - prices: cusProduct.customer_prices.map((cp) => cp.price), - entitlements: cusProduct.customer_entitlements.map( - (ce) => ce.entitlement - ), - }; + return { + ...cusProduct.product, + prices: cusProduct.customer_prices.map((cp) => cp.price), + entitlements: cusProduct.customer_entitlements.map((ce) => ce.entitlement), + }; }; export const searchCusProducts = ({ - productId, - internalProductId, - cusProducts, - status, + productId, + internalProductId, + cusProducts, + status, }: { - productId?: string; - internalProductId?: string; - cusProducts: FullCusProduct[]; - status?: CusProductStatus; + productId?: string; + internalProductId?: string; + cusProducts: FullCusProduct[]; + status?: CusProductStatus; }) => { - if (!cusProducts) { - return undefined; - } - return cusProducts.find((cusProduct: FullCusProduct) => { - let prodIdMatch = false; - if (productId) { - prodIdMatch = cusProduct.product.id === productId; - } else if (internalProductId) { - prodIdMatch = cusProduct.product.internal_id === internalProductId; - } - return prodIdMatch && (status ? cusProduct.status === status : true); - }); + if (!cusProducts) { + return undefined; + } + return cusProducts.find((cusProduct: FullCusProduct) => { + let prodIdMatch = false; + if (productId) { + prodIdMatch = cusProduct.product.id === productId; + } else if (internalProductId) { + prodIdMatch = cusProduct.product.internal_id === internalProductId; + } + return prodIdMatch && (status ? cusProduct.status === status : true); + }); }; export const isTrialing = (cusProduct: FullCusProduct) => { - return cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now(); + return cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now(); }; export const getMainCusProduct = async ({ - db, - internalCustomerId, - productGroup, + db, + internalCustomerId, + productGroup, }: { - db: DrizzleCli; - internalCustomerId: string; - productGroup?: string; + db: DrizzleCli; + internalCustomerId: string; + productGroup?: string; }) => { - let cusProducts = await CusProductService.list({ - db, - internalCustomerId, - inStatuses: RELEVANT_STATUSES, - }); + let cusProducts = await CusProductService.list({ + db, + internalCustomerId, + inStatuses: RELEVANT_STATUSES, + }); - let mainCusProduct = cusProducts.find( - (cusProduct: FullCusProduct) => - !cusProduct.product.is_add_on && - (productGroup ? cusProduct.product.group === productGroup : true) - ); + let mainCusProduct = cusProducts.find( + (cusProduct: FullCusProduct) => + !cusProduct.product.is_add_on && + (productGroup ? cusProduct.product.group === productGroup : true) + ); - return mainCusProduct as FullCusProduct; + return mainCusProduct as FullCusProduct; }; export const getCusProductsWithStripeSubId = ({ - cusProducts, - stripeSubId, - curCusProductId, + cusProducts, + stripeSubId, + curCusProductId, }: { - cusProducts: FullCusProduct[]; - stripeSubId: string; - curCusProductId?: string; + cusProducts: FullCusProduct[]; + stripeSubId: string; + curCusProductId?: string; }) => { - return cusProducts.filter( - (cusProduct) => - cusProduct.subscription_ids?.includes(stripeSubId) && - cusProduct.id !== curCusProductId - ); + return cusProducts.filter( + (cusProduct) => + cusProduct.subscription_ids?.includes(stripeSubId) && + cusProduct.id !== curCusProductId + ); }; export const getFeatureQuantity = ({ - cusProduct, - internalFeatureId, + cusProduct, + internalFeatureId, }: { - cusProduct: FullCusProduct; - internalFeatureId: string; + cusProduct: FullCusProduct; + internalFeatureId: string; }) => { - const options = cusProduct.options; - const option = options.find( - (o) => o.internal_feature_id == internalFeatureId - ); - return nullish(option?.quantity) ? 1 : option?.quantity!; + const options = cusProduct.options; + const option = options.find( + (o) => o.internal_feature_id == internalFeatureId + ); + return nullish(option?.quantity) ? 1 : option?.quantity!; }; diff --git a/server/src/internal/customers/cusUtils/createNewCustomer.ts b/server/src/internal/customers/cusUtils/createNewCustomer.ts index 28df8e1fe..ea73d0868 100644 --- a/server/src/internal/customers/cusUtils/createNewCustomer.ts +++ b/server/src/internal/customers/cusUtils/createNewCustomer.ts @@ -1,4 +1,3 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { addCustomerCreatedTask } from "@/internal/analytics/handlers/handleCustomerCreated.js"; import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; @@ -7,15 +6,15 @@ import { isFreeProduct } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { - Organization, - CreateCustomer, - CreateCustomerSchema, - ErrCode, - BillingInterval, - AttachScenario, - FullCustomer, + CreateCustomer, + CreateCustomerSchema, + ErrCode, + BillingInterval, + AttachScenario, + FullCustomer, + FullProduct, } from "@autumn/shared"; -import { AppEnv, Customer } from "@autumn/shared"; +import { Customer } from "@autumn/shared"; import { createFullCusProduct } from "../add-product/createFullCusProduct.js"; import { handleAddProduct } from "../attach/attachFunctions/addProductFlow/handleAddProduct.js"; import { CusService } from "../CusService.js"; @@ -23,148 +22,216 @@ import { initStripeCusAndProducts } from "../handlers/handleCreateCustomer.js"; import { generateId } from "@/utils/genUtils.js"; import { - newCusToAttachParams, - newCusToInsertParams, + newCusToAttachParams, + newCusToInsertParams, } from "../attach/attachUtils/attachParams/convertToParams.js"; import { isDefaultTrialFullProduct } from "@/internal/products/productUtils/classifyProduct.js"; -export const createNewCustomer = async ({ - req, - customer, - nextResetAt, - createDefaultProducts = true, +export const getGroupToDefaultProd = async ({ + defaultProds, }: { - req: ExtendedRequest; - customer: CreateCustomer; - nextResetAt?: number; - createDefaultProducts?: boolean; + defaultProds: FullProduct[]; }) => { - const { db, org, env, logger } = req; + const groups = new Set(defaultProds.map((p) => p.group)); + const groupToDefaultProd: Record = {}; - logger.info( - `Creating customer: ${customer.email || customer.id}, org: ${org.slug}` - ); + for (const group of groups) { + const defaultProdsInGroup = defaultProds.filter((p) => p.group === group); - const defaultProds = await ProductService.listDefault({ - db, - orgId: org.id, - env, - }); + if (defaultProdsInGroup.length === 0) continue; - const nonFreeProds = defaultProds.filter((p) => !isFreeProduct(p.prices)); - const freeProds = defaultProds.filter((p) => isFreeProduct(p.prices)); - const defaultPaidTrialProd = nonFreeProds.find((p) => - isDefaultTrialFullProduct({ product: p }) - ); + defaultProdsInGroup.sort((a, b) => { + // 1. If a is default trial, go first + if (isDefaultTrialFullProduct({ product: a })) return -1; - const parsedCustomer = CreateCustomerSchema.parse(customer); + if (!isFreeProduct(a.prices)) return -1; - const customerData: Customer = { - ...parsedCustomer, + return 0; + }); - name: parsedCustomer.name || "", - email: - nonFreeProds.length > 0 && !parsedCustomer.email - ? `${parsedCustomer.id}-${org.id}@invoices.useautumn.com` - : parsedCustomer.email || "", + groupToDefaultProd[group] = defaultProdsInGroup[0]; + } - metadata: parsedCustomer.metadata || {}, - internal_id: generateId("cus"), - org_id: org.id, - created_at: Date.now(), - env, - processor: parsedCustomer.stripe_id - ? { - id: parsedCustomer.stripe_id, - type: "stripe", - } - : undefined, - }; - - // Check if stripeCli exists - if (nonFreeProds.length > 0) { - createStripeCli({ - org, - env, - }); - - if (!customerData?.email) { - throw new RecaseError({ - code: ErrCode.InvalidRequest, - message: - "Customer email is required to attach default product with prices", - }); - } - } - - const newCustomer = await CusService.insert({ - db, - data: customerData, - }); - - if (!newCustomer) { - throw new RecaseError({ - code: ErrCode.InternalError, - message: "CusService.insert returned null", - }); - } - - if (!createDefaultProducts) { - return newCustomer; - } - - await addCustomerCreatedTask({ - req, - internalCustomerId: newCustomer.internal_id, - org, - env, - }); - - if (nonFreeProds.length > 0) { - const stripeCli = createStripeCli({ org, env }); - - await initStripeCusAndProducts({ - db, - org, - env, - customer: newCustomer, - products: nonFreeProds, - logger, - }); - - await handleAddProduct({ - req, - attachParams: newCusToAttachParams({ - req, - newCus: newCustomer as FullCustomer, - products: nonFreeProds, - stripeCli, - freeTrial: defaultPaidTrialProd?.free_trial || null, - }), - }); - } - - if (!defaultPaidTrialProd) { - for (const product of freeProds) { - await createFullCusProduct({ - db, - attachParams: newCusToInsertParams({ - req, - newCus: newCustomer, - product, - }), - nextResetAt, - anchorToUnix: org.config.anchor_start_of_month - ? getNextStartOfMonthUnix({ - interval: BillingInterval.Month, - intervalCount: 1, - }) - : undefined, - scenario: AttachScenario.New, - logger, - }); - } - } - - return newCustomer; + return groupToDefaultProd; +}; + +export const createNewCustomer = async ({ + req, + customer, + nextResetAt, + createDefaultProducts = true, +}: { + req: ExtendedRequest; + customer: CreateCustomer; + nextResetAt?: number; + createDefaultProducts?: boolean; +}) => { + const { db, org, env, logger } = req; + + logger.info( + `Creating customer: ${customer.email || customer.id}, org: ${org.slug}` + ); + + const defaultProds = await ProductService.listDefault({ + db, + orgId: org.id, + env, + }); + + const nonFreeProds = defaultProds.filter((p) => !isFreeProduct(p.prices)); + // const freeProds = defaultProds.filter((p) => isFreeProduct(p.prices)); + // const defaultPaidTrialProd = nonFreeProds.find((p) => + // isDefaultTrialFullProduct({ product: p }) + // ); + + const parsedCustomer = CreateCustomerSchema.parse(customer); + + const customerData: Customer = { + ...parsedCustomer, + + name: parsedCustomer.name || "", + email: + nonFreeProds.length > 0 && !parsedCustomer.email + ? `${parsedCustomer.id}-${org.id}@invoices.useautumn.com` + : parsedCustomer.email || "", + + metadata: parsedCustomer.metadata || {}, + internal_id: generateId("cus"), + org_id: org.id, + created_at: Date.now(), + env, + processor: parsedCustomer.stripe_id + ? { + id: parsedCustomer.stripe_id, + type: "stripe", + } + : undefined, + }; + + // Check if stripeCli exists + if (nonFreeProds.length > 0) { + createStripeCli({ org, env }); + } + + const newCustomer = await CusService.insert({ + db, + data: customerData, + }); + + if (!newCustomer) { + throw new RecaseError({ + code: ErrCode.InternalError, + message: "CusService.insert returned null", + }); + } + + if (!createDefaultProducts) { + return newCustomer; + } + + await addCustomerCreatedTask({ + req, + internalCustomerId: newCustomer.internal_id, + org, + env, + }); + + const groupToDefaultProd = await getGroupToDefaultProd({ + defaultProds, + }); + + for (const group in groupToDefaultProd) { + const defaultProd = groupToDefaultProd[group]; + + if (!isFreeProduct(defaultProd.prices)) { + let stripeCli = null; + + stripeCli = createStripeCli({ org, env }); + await initStripeCusAndProducts({ + db, + org, + env, + customer: newCustomer, + products: nonFreeProds, + logger, + }); + + await handleAddProduct({ + req, + attachParams: newCusToAttachParams({ + req, + newCus: newCustomer as FullCustomer, + products: [defaultProd], + stripeCli, + freeTrial: defaultProd.free_trial || null, + }), + }); + } else { + await createFullCusProduct({ + db, + attachParams: newCusToInsertParams({ + req, + newCus: newCustomer, + product: defaultProd, + }), + nextResetAt, + anchorToUnix: org.config.anchor_start_of_month + ? getNextStartOfMonthUnix({ + interval: BillingInterval.Month, + intervalCount: 1, + }) + : undefined, + scenario: AttachScenario.New, + logger, + }); + } + } + + // if (nonFreeProds.length > 0) { + // const stripeCli = createStripeCli({ org, env }); + + // await initStripeCusAndProducts({ + // db, + // org, + // env, + // customer: newCustomer, + // products: nonFreeProds, + // logger, + // }); + + // await handleAddProduct({ + // req, + // attachParams: newCusToAttachParams({ + // req, + // newCus: newCustomer as FullCustomer, + // products: nonFreeProds, + // stripeCli, + // freeTrial: defaultPaidTrialProd?.free_trial || null, + // }), + // }); + // } + + // if (!defaultPaidTrialProd) { + // for (const product of freeProds) { + // await createFullCusProduct({ + // db, + // attachParams: newCusToInsertParams({ + // req, + // newCus: newCustomer, + // product, + // }), + // nextResetAt, + // anchorToUnix: org.config.anchor_start_of_month + // ? getNextStartOfMonthUnix({ + // interval: BillingInterval.Month, + // intervalCount: 1, + // }) + // : undefined, + // scenario: AttachScenario.New, + // logger, + // }); + // } + // } + + return newCustomer; }; diff --git a/server/src/internal/customers/handlers/handleCreateCustomer.ts b/server/src/internal/customers/handlers/handleCreateCustomer.ts index dc02fd4b2..e31051df2 100644 --- a/server/src/internal/customers/handlers/handleCreateCustomer.ts +++ b/server/src/internal/customers/handlers/handleCreateCustomer.ts @@ -185,7 +185,6 @@ export const handleCreateCustomer = async ({ createDefaultProducts?: boolean; }) => { const newCus = CreateCustomerSchema.parse(cusData); - console.log("newCus", newCus); // 1. If no ID and email is not NULL let createdCustomer; diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index eb196d3af..5942e1d1b 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -115,16 +115,19 @@ export class ProductService { db, orgId, env, + group, }: { db: DrizzleCli; orgId: string; env: AppEnv; + group?: string; }) { let prods = (await db.query.products.findMany({ where: and( eq(products.org_id, orgId), eq(products.env, env), - eq(products.is_default, true) + eq(products.is_default, true), + group ? eq(products.group, group) : undefined ), with: { entitlements: { diff --git a/server/src/internal/products/handlers/handleCreateProduct.ts b/server/src/internal/products/handlers/handleCreateProduct.ts index ea69b686d..561b59af6 100644 --- a/server/src/internal/products/handlers/handleCreateProduct.ts +++ b/server/src/internal/products/handlers/handleCreateProduct.ts @@ -13,7 +13,10 @@ import { FreeTrial, FullProduct, Price, + Product, + ProductItem, ProductResponseSchema, + ProductV2, } from "@autumn/shared"; import { keyToTitle, @@ -25,13 +28,15 @@ import { import { ProductService } from "@/internal/products/ProductService.js"; import { constructProduct, + getGroupToDefaults, initProductInStripe, } from "@/internal/products/productUtils.js"; import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; -import { detectBaseVariant } from "../productUtils/detectProductVariant.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; import { JobName } from "@/queue/JobName.js"; +import { isDefaultTrial } from "../productUtils/classifyProduct.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => { let { free_trial, items } = req.body; @@ -95,6 +100,67 @@ const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => { }; }; +export const disableCurrentDefault = async ({ + req, + newProduct, + items, + freeTrial, +}: { + req: ExtendedRequest; + newProduct: Product; + items: ProductItem[]; + + freeTrial: FreeTrial | null; +}) => { + const { db, org, env, logger } = req; + let defaultProds = await ProductService.listDefault({ + db, + orgId: org.id, + env, + }); + + defaultProds = defaultProds.filter((prod) => prod.id !== newProduct.id); + + if (defaultProds.length === 0) return; + + const defaults = getGroupToDefaults({ + defaultProds, + })?.[newProduct.group]; + + const willBeDefaultTrial = isDefaultTrial({ + product: { + ...newProduct, + free_trial: freeTrial, + items: items || [], + }, + }); + + if (willBeDefaultTrial) { + // Disable current default trial + const curDefault = defaults?.defaultTrial; + if (curDefault) { + logger.info( + `Disabling trial on cur default trial product: ${curDefault.id}` + ); + await ProductService.updateByInternalId({ + db, + internalId: curDefault.internal_id, + update: { is_default: false }, + }); + } + } else if (newProduct.is_default) { + const curDefault = defaults?.free; + if (curDefault) { + logger.info(`Disabling trial on cur default product: ${curDefault.id}`); + await ProductService.updateByInternalId({ + db, + internalId: curDefault.internal_id, + update: { is_default: false }, + }); + } + } +}; + export const handleCreateProduct = async (req: Request, res: any) => routeHandler({ req, @@ -114,6 +180,13 @@ export const handleCreateProduct = async (req: Request, res: any) => env, }); + await disableCurrentDefault({ + req, + newProduct, + items, + freeTrial: freeTrial || null, + }); + let product = await ProductService.insert({ db, product: newProduct }); let prices: Price[] = []; diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts index 5c4463b79..8064999aa 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts @@ -17,7 +17,11 @@ import { addTaskToQueue } from "@/queue/queueUtils.js"; import { JobName } from "@/queue/JobName.js"; import { productsAreSame } from "../../productUtils/compareProductUtils.js"; import { initProductInStripe } from "../../productUtils.js"; -import { handleCreateProduct } from "../handleCreateProduct.js"; +import { + disableCurrentDefault, + handleCreateProduct, +} from "../handleCreateProduct.js"; +import { mapToProductItems } from "../../productV2Utils.js"; export const handleUpdateProductV2 = async (req: any, res: any) => routeHandler({ @@ -29,27 +33,32 @@ export const handleUpdateProductV2 = async (req: any, res: any) => const { version, upsert, disable_version } = req.query; const { orgId, env, logger, db } = req; - const [features, org, fullProduct, rewardPrograms] = await Promise.all([ - FeatureService.getFromReq(req), - OrgService.getFromReq(req), - ProductService.getFull({ - db, - idOrInternalId: productId, - orgId, - env, - version: version ? parseInt(version) : undefined, - allowNotFound: upsert == "true", - }), - RewardProgramService.getByProductId({ - db, - productIds: [productId], - orgId, - env, - }), - ]); + const [features, org, fullProduct, rewardPrograms, defaultProds] = + await Promise.all([ + FeatureService.getFromReq(req), + OrgService.getFromReq(req), + ProductService.getFull({ + db, + idOrInternalId: productId, + orgId, + env, + version: version ? parseInt(version) : undefined, + allowNotFound: upsert == "true", + }), + RewardProgramService.getByProductId({ + db, + productIds: [productId], + orgId, + env, + }), + ProductService.listDefault({ + db, + orgId, + env, + }), + ]); if (!fullProduct) { - console.log("Upserting:", upsert); if (upsert == "true") { await handleCreateProduct(req, res); return; @@ -70,10 +79,31 @@ export const handleUpdateProductV2 = async (req: any, res: any) => let cusProductExists = cusProductsCurVersion.length > 0; + // console.log("Updating product", { + // id: fullProduct.id, + // body: req.body, + // }); + await disableCurrentDefault({ + req, + newProduct: { + ...fullProduct, + ...req.body, + }, + items: + req.body.items || + mapToProductItems({ + prices: fullProduct.prices, + entitlements: fullProduct.entitlements, + features, + }), + freeTrial: req.body.free_trial || fullProduct.free_trial || null, + }); + await handleUpdateProductDetails({ db, curProduct: fullProduct, newProduct: UpdateProductSchema.parse(req.body), + newFreeTrial: req.body.free_trial, items: req.body.items, org, rewardPrograms, @@ -81,7 +111,6 @@ export const handleUpdateProductV2 = async (req: any, res: any) => }); let itemsExist = notNullish(req.body.items); - if (cusProductExists && itemsExist) { if (disable_version == "true") { throw new RecaseError({ diff --git a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts index b72e0b367..9c422b2d8 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts @@ -3,235 +3,281 @@ import { CusProductService } from "@/internal/customers/cusProducts/CusProductSe import RecaseError from "@/utils/errorUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import { - AppEnv, - ErrCode, - FullProduct, - Organization, - Product, - ProductItem, - RewardProgram, - UpdateProduct, + AppEnv, + CreateFreeTrial, + ErrCode, + FreeTrial, + FullProduct, + isFreeProductV2, + Organization, + Product, + ProductItem, + RewardProgram, + UpdateProduct, } from "@autumn/shared"; import { ProductService } from "../../ProductService.js"; import { FreeTrialService } from "../../free-trials/FreeTrialService.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { usagePriceToProductName } from "../../prices/priceUtils/usagePriceUtils/convertUsagePrice.js"; import { - isFeaturePriceItem, - isPriceItem, + 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) { - return false; - } + if (notNullish(prod2.id) && prod1.id != prod2.id) { + return false; + } - if (notNullish(prod2.name) && prod1.name != prod2.name) { - return false; - } + if (notNullish(prod2.name) && prod1.name != prod2.name) { + return false; + } - if (notNullish(prod2.group) && prod1.group != prod2.group) { - return false; - } + if (notNullish(prod2.group) && prod1.group != prod2.group) { + return false; + } - if (notNullish(prod2.is_add_on) && prod1.is_add_on != prod2.is_add_on) { - return false; - } + if (notNullish(prod2.is_add_on) && prod1.is_add_on != prod2.is_add_on) { + return false; + } - if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) { - return false; - } + if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) { + return false; + } - if (notNullish(prod2.archived) && prod1.archived !== prod2.archived) { - return false; - } + if (notNullish(prod2.archived) && prod1.archived !== prod2.archived) { + return false; + } - return true; + return true; }; const updateStripeProductNames = async ({ - db, - org, - curProduct, - newName, - logger, + db, + org, + curProduct, + newName, + logger, }: { - db: DrizzleCli; - org: Organization; - curProduct: FullProduct; - newName: string; - logger: any; + db: DrizzleCli; + org: Organization; + curProduct: FullProduct; + newName: string; + logger: any; }) => { - if (!isStripeConnected({ org, env: curProduct.env as AppEnv })) return; + if (!isStripeConnected({ org, env: curProduct.env as AppEnv })) return; - const stripeCli = createStripeCli({ - org, - env: curProduct.env as AppEnv, - }); - let stripeProdId = curProduct.processor?.id; + const stripeCli = createStripeCli({ + org, + env: curProduct.env as AppEnv, + }); + let stripeProdId = curProduct.processor?.id; - if (!stripeProdId || !newName) { - return; - } + if (!stripeProdId || !newName) { + return; + } - try { - await stripeCli.products.update(stripeProdId, { - name: newName, - }); - } catch (error: any) { - logger.error( - `Error updating product ${curProduct.id} name in Stripe: ${error.message}`, - { - error, - stripeProdId, - newName, - } - ); - } + try { + await stripeCli.products.update(stripeProdId, { + name: newName, + }); + } catch (error: any) { + logger.error( + `Error updating product ${curProduct.id} name in Stripe: ${error.message}`, + { + error, + stripeProdId, + newName, + } + ); + } - for (const price of curProduct.prices) { - let stripeProdId = price.config?.stripe_product_id; + for (const price of curProduct.prices) { + let stripeProdId = price.config?.stripe_product_id; - if (stripeProdId) { - let name = usagePriceToProductName({ - price, - fullProduct: { - ...curProduct, - name: newName, - }, - }); + if (stripeProdId) { + let name = usagePriceToProductName({ + price, + fullProduct: { + ...curProduct, + name: newName, + }, + }); - try { - await stripeCli.products.update(stripeProdId, { - name, - }); - } catch (error: any) { - logger.error( - `Error updating price ${price.id} name in Stripe: ${error.message}` - ); - } - } - } + try { + await stripeCli.products.update(stripeProdId, { + name, + }); + } catch (error: any) { + logger.error( + `Error updating price ${price.id} name in Stripe: ${error.message}` + ); + } + } + } +}; + +const willBeDefaultTrial = ({ + newProduct, + curProduct, + newFreeTrial, + newItems, +}: { + newProduct: UpdateProduct; + curProduct: FullProduct; + newFreeTrial: FreeTrial; + newItems: ProductItem[]; +}) => { + // 1. Get final default + const finalDefault = notNullish(newProduct.is_default) + ? newProduct.is_default + : curProduct.is_default; + + const finalFreeTrial = notNullish(newFreeTrial) + ? newFreeTrial + : curProduct.free_trial; + + const finalIsFree = notNullish(newItems) + ? isFreeProductV2({ items: newItems }) + : isFreeProduct(curProduct.prices); + + return finalDefault && !finalIsFree && finalFreeTrial; }; export const handleUpdateProductDetails = async ({ - db, - newProduct, - curProduct, - items, - org, - rewardPrograms, - logger, + db, + newProduct, + curProduct, + newFreeTrial, + items, + org, + rewardPrograms, + logger, }: { - db: DrizzleCli; - curProduct: FullProduct; - newProduct: UpdateProduct; - items: ProductItem[]; - org: Organization; - rewardPrograms: RewardProgram[]; - logger: any; + db: DrizzleCli; + curProduct: FullProduct; + newProduct: UpdateProduct; + newFreeTrial: FreeTrial; + items: ProductItem[]; + org: Organization; + rewardPrograms: RewardProgram[]; + logger: any; }) => { - const customersOnAllVersions = await CusProductService.getByProductId({ - db, - productId: curProduct.id, - orgId: org.id, - env: curProduct.env as AppEnv, - }); + const customersOnAllVersions = await CusProductService.getByProductId({ + db, + productId: curProduct.id, + orgId: org.id, + env: curProduct.env as AppEnv, + }); - const trialConfig = await FreeTrialService.getByProductId({ - db, - productId: curProduct.internal_id, - }); + const trialConfig = await FreeTrialService.getByProductId({ + db, + productId: curProduct.internal_id, + }); - // Should error if: - // - New product is a default product - // - Org is not allowed to have paid default products - // - Current product is not a default trial - if (newProduct.is_default && !org.config.allow_paid_default && !isDefaultTrialFullProduct({ product: curProduct, skipDefault: true })) { - // 1. Check if there are items - if (items) { - if ( - items.some( - (item) => isFeaturePriceItem(item) || isPriceItem(item) - ) - ) { - throw new RecaseError({ - 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({ - message: - "Cannot make a product default if it has fixed prices or paid features", - code: ErrCode.InvalidProduct, - statusCode: 400, - }); - } - } - } + // Should error if: + // - New product is a default product + // - Org is not allowed to have paid default products + // - Current product is not a default trial - if (productDetailsSame(curProduct, newProduct)) { - return; - } + // Final prices are curProduct.prices or newProduct.prices - if (notNullish(newProduct.id) && newProduct.id !== curProduct.id) { - if (customersOnAllVersions.length > 0) { - throw new RecaseError({ - message: - "Cannot change product ID because it has existing customers", - code: ErrCode.ProductHasCustomers, - statusCode: 400, - }); - } + if ( + newProduct.is_default && + !org.config.allow_paid_default && + !willBeDefaultTrial({ + newProduct, + curProduct, + 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({ + 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({ + message: + "Cannot make a product default if it has fixed prices or paid features", + code: ErrCode.InvalidProduct, + statusCode: 400, + }); + } + } + } - if (rewardPrograms.length > 0) { - throw new RecaseError({ - message: - "Cannot change product ID because existing reward programs are linked to it", - code: ErrCode.ProductHasRewardPrograms, - statusCode: 400, - }); - } - } + if (productDetailsSame(curProduct, newProduct)) { + return; + } - // 2. Update product - await ProductService.updateByInternalId({ - db, - internalId: curProduct.internal_id, - update: { - id: newProduct.id, - name: newProduct.name, - group: newProduct.group, - is_add_on: newProduct.is_add_on, - is_default: newProduct.is_default, - archived: newProduct.archived, - }, - }); + if (notNullish(newProduct.id) && newProduct.id !== curProduct.id) { + if (customersOnAllVersions.length > 0) { + throw new RecaseError({ + message: "Cannot change product ID because it has existing customers", + code: ErrCode.ProductHasCustomers, + statusCode: 400, + }); + } - // Update product name in Stripe - if (curProduct.name !== newProduct.name && notNullish(newProduct.name)) { - logger.info( - `Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}` - ); - await updateStripeProductNames({ - db, - org, - curProduct, - newName: newProduct.name!, - logger, - }); - } + if (rewardPrograms.length > 0) { + throw new RecaseError({ + message: + "Cannot change product ID because existing reward programs are linked to it", + code: ErrCode.ProductHasRewardPrograms, + statusCode: 400, + }); + } + } - curProduct.name = newProduct.name || curProduct.name; - curProduct.group = newProduct.group || curProduct.group; - curProduct.is_add_on = newProduct.is_add_on ?? curProduct.is_add_on; - curProduct.is_default = newProduct.is_default ?? curProduct.is_default; - curProduct.archived = newProduct.archived ?? curProduct.archived; + // 2. Update product + await ProductService.updateByInternalId({ + db, + internalId: curProduct.internal_id, + update: { + id: newProduct.id, + name: newProduct.name, + group: newProduct.group, + is_add_on: newProduct.is_add_on, + is_default: newProduct.is_default, + archived: newProduct.archived, + }, + }); + + // Update product name in Stripe + if (curProduct.name !== newProduct.name && notNullish(newProduct.name)) { + logger.info( + `Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}` + ); + await updateStripeProductNames({ + db, + org, + curProduct, + newName: newProduct.name!, + logger, + }); + } + + curProduct.name = newProduct.name || curProduct.name; + curProduct.group = newProduct.group || curProduct.group; + curProduct.is_add_on = newProduct.is_add_on ?? curProduct.is_add_on; + curProduct.is_default = newProduct.is_default ?? curProduct.is_default; + curProduct.archived = newProduct.archived ?? curProduct.archived; }; diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index b817f6aa4..39baea229 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -6,7 +6,7 @@ import { ErrCode, UsageModel } from "@autumn/shared"; import { FeatureOptions } from "@autumn/shared"; import { OrgService } from "../orgs/OrgService.js"; import { RewardService } from "../rewards/RewardService.js"; -import { getProductVersionCounts } from "./productUtils.js"; +import { getGroupToDefaults, getProductVersionCounts } from "./productUtils.js"; import { getLatestProducts } from "./productUtils.js"; import { CusProdReadService } from "../customers/cusProducts/CusProdReadService.js"; import { MigrationService } from "../migrations/MigrationService.js"; @@ -20,6 +20,7 @@ import RecaseError, { } from "@/utils/errorUtils.js"; import { createOrgResponse } from "../orgs/orgUtils.js"; import { sortFullProducts } from "./productUtils/sortProductUtils.js"; +import { getGroupToDefaultProd } from "../customers/cusUtils/createNewCustomer.js"; export const productRouter: Router = Router({ mergeParams: true }); @@ -29,7 +30,7 @@ productRouter.get("/data", async (req: any, res) => { const allVersions = req.query.all_versions === "true"; - const [products, features, org, coupons, rewardPrograms] = + const [products, features, org, coupons, rewardPrograms, defaultProds] = await Promise.all([ ProductService.listFull({ db, @@ -46,12 +47,21 @@ productRouter.get("/data", async (req: any, res) => { orgId: req.orgId, env: req.env, }), + ProductService.listDefault({ + db, + orgId: req.orgId, + env: req.env, + }), ]); sortFullProducts({ products: getLatestProducts(products), }); + const groupToDefaultProd = getGroupToDefaults({ + defaultProds, + }); + res.status(200).json({ products: products.map((product) => { return mapToProductV2({ product, features }); @@ -61,6 +71,7 @@ productRouter.get("/data", async (req: any, res) => { org: createOrgResponse(org), rewards: coupons, rewardPrograms, + groupToDefaults: groupToDefaultProd, }); } catch (error) { console.error("Failed to get products", error); @@ -73,7 +84,7 @@ productRouter.post("/data", async (req: any, res) => { let { db } = req; let { showArchived } = req.body; - const [products, features, org, coupons, rewardPrograms] = + const [products, defaultProds, features, org, coupons, rewardPrograms] = await Promise.all([ ProductService.listFull({ db, @@ -82,6 +93,11 @@ productRouter.post("/data", async (req: any, res) => { // returnAll: true, archived: showArchived, }), + ProductService.listDefault({ + db, + orgId: req.orgId, + env: req.env, + }), FeatureService.getFromReq(req), OrgService.getFromReq(req), RewardService.list({ db, orgId: req.orgId, env: req.env }), @@ -92,10 +108,16 @@ productRouter.post("/data", async (req: any, res) => { }), ]); + // Group to default product + const groupToDefaultProd = getGroupToDefaults({ + defaultProds, + }); + res.status(200).json({ products: sortFullProducts({ products }).map((product) => { return mapToProductV2({ product, features }); }), + groupToDefaults: groupToDefaultProd, versionCounts: getProductVersionCounts(products), features, org: createOrgResponse(org), @@ -191,6 +213,18 @@ productRouter.get("/:productId/data", async (req: any, res) => { statusCode: StatusCodes.NOT_FOUND, }); } + + const defaultProds = await ProductService.listDefault({ + db, + orgId: req.orgId, + env: req.env, + group: product.group, + }); + + const groupDefaults = getGroupToDefaults({ + defaultProds, + })?.[product.group]; + let entitlements = product.entitlements; let prices = product.prices; @@ -218,6 +252,7 @@ productRouter.get("/:productId/data", async (req: any, res) => { }, numVersions, existingMigrations, + groupDefaults: groupDefaults, }); } catch (error) { handleFrontendReqError({ diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index 851ef41b4..538cd7ea0 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -43,6 +43,7 @@ import { FreeTrialService } from "./free-trials/FreeTrialService.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { compareBillingIntervals } from "./prices/priceUtils/priceIntervalUtils.js"; import { isStripeConnected } from "../orgs/orgUtils.js"; +import { isDefaultTrialFullProduct } from "./productUtils/classifyProduct.js"; export const getLatestProducts = (products: FullProduct[]) => { const latestProducts = products.reduce((acc: any, product: any) => { @@ -534,3 +535,27 @@ export const searchProductsByStripeId = async ({ }) => { return products.find((p) => p.processor?.id === stripeId); }; + +export const getGroupToDefaults = ({ + defaultProds, +}: { + defaultProds: FullProduct[]; +}) => { + const groupToDefaults: Record> = {}; + + for (const product of defaultProds) { + if (!groupToDefaults[product.group]) { + groupToDefaults[product.group] = {}; + } + + if (isDefaultTrialFullProduct({ product })) { + groupToDefaults[product.group].defaultTrial = product; + } + + if (isFreeProduct(product.prices)) { + groupToDefaults[product.group].free = product; + } + } + + return groupToDefaults; +}; diff --git a/server/src/internal/products/productUtils/classifyProduct.ts b/server/src/internal/products/productUtils/classifyProduct.ts index 189740e1c..68ae2479b 100644 --- a/server/src/internal/products/productUtils/classifyProduct.ts +++ b/server/src/internal/products/productUtils/classifyProduct.ts @@ -35,10 +35,32 @@ export const isFreeProductV2 = ({ product }: { product: ProductV2 }) => { return product.items.every((item) => isFeatureItem(item)); }; -export const isDefaultTrial = ({ product, skipDefault = false }: { product: ProductV2, skipDefault?: boolean }) => { - return product.free_trial && !product.free_trial?.card_required && (product.is_default || skipDefault) && !isFreeProductV2({ product }); +export const isDefaultTrial = ({ + product, + skipDefault = false, +}: { + product: ProductV2; + skipDefault?: boolean; +}) => { + return ( + product.free_trial && + !product.free_trial?.card_required && + (product.is_default || skipDefault) && + !isFreeProductV2({ product }) + ); }; -export const isDefaultTrialFullProduct = ({ product, skipDefault = false }: { product: FullProduct, skipDefault?: boolean }) => { - return product.free_trial && !product.free_trial?.card_required && (product.is_default || skipDefault) && !isFreeProduct(product.prices); -}; \ No newline at end of file +export const isDefaultTrialFullProduct = ({ + product, + skipDefault = false, +}: { + product: FullProduct; + skipDefault?: boolean; +}) => { + return ( + product.free_trial && + !product.free_trial?.card_required && + (product.is_default || skipDefault) && + !isFreeProduct(product.prices) + ); +}; diff --git a/server/src/utils/scriptUtils/createTestProducts.ts b/server/src/utils/scriptUtils/createTestProducts.ts index 9b03d8d64..144a7b463 100644 --- a/server/src/utils/scriptUtils/createTestProducts.ts +++ b/server/src/utils/scriptUtils/createTestProducts.ts @@ -83,6 +83,7 @@ export const constructProduct = ({ items, type, interval, + group, intervalCount, isAnnual = false, trial = false, @@ -96,6 +97,7 @@ export const constructProduct = ({ items: ProductItem[]; type: "free" | "pro" | "premium" | "growth" | "one_off"; interval?: BillingInterval; + group?: string; intervalCount?: number; isAnnual?: boolean; trial?: boolean; @@ -152,9 +154,9 @@ export const constructProduct = ({ : keyToTitle(type), items, is_add_on: isAddOn, - is_default: (type == "free" && isDefault) || (forcePaidDefault), + is_default: (type == "free" && isDefault) || forcePaidDefault, version: 1, - group: "", + group: group || "", free_trial: freeTrial || trial ? (CreateFreeTrialSchema.parse({ diff --git a/server/src/utils/scriptUtils/initCustomer.ts b/server/src/utils/scriptUtils/initCustomer.ts index c7b36dd29..bc0759141 100644 --- a/server/src/utils/scriptUtils/initCustomer.ts +++ b/server/src/utils/scriptUtils/initCustomer.ts @@ -93,9 +93,7 @@ export const initCustomer = async ({ } try { - const response = await autumn.customers.create(customerData); - - // console.log("Created customer:", response); + const res = await autumn.customers.create(customerData); let customer = (await CusService.get({ db, @@ -104,17 +102,6 @@ export const initCustomer = async ({ env: env, })) as Customer; - // console.log("Org ID:", org.id); - // console.log("Env:", env); - // console.log("Customer ID:", customerId); - - // console.log("Customer:", customer); - - // console.log("customer id", customerId); - // console.log("org id", org.id); - // console.log("env", env); - // console.log("customer", customer); - const stripeCli = createStripeCli({ org: org, env: env }); let testClockId = ""; if (withTestClock) { @@ -152,3 +139,101 @@ export const initCustomer = async ({ throw error; } }; + +export const attachPaymentMethod = async ({ + stripeCli, + stripeCusId, + type, +}: { + stripeCli: Stripe; + stripeCusId: string; + type: "success" | "fail"; +}) => { + try { + let token = type === "fail" ? "tok_chargeCustomerFail" : "tok_visa"; + const pm = await stripeCli.paymentMethods.create({ + type: "card", + card: { + token, + }, + }); + + await stripeCli.paymentMethods.attach(pm.id, { + customer: stripeCusId, + }); + + await stripeCli.customers.update(stripeCusId, { + invoice_settings: { + default_payment_method: pm.id, + }, + }); + } catch (error) { + console.log("failed to attach payment method", error); + } +}; + +// V2 initializes the customer in Stripe, then creates the customer in Autumn +export const initCustomerV2 = async ({ + autumn, + customerId, + org, + env, + db, + attachPm, + withTestClock = true, +}: { + autumn: Autumn; + customerId: string; + org: Organization; + env: AppEnv; + db: DrizzleCli; + attachPm?: "success" | "fail"; + withTestClock?: boolean; +}) => { + let name = customerId; + let email = `${customerId}@example.com`; + let fingerprint_ = ""; + const stripeCli = createStripeCli({ org, env }); + + let testClockId = undefined; + + if (withTestClock) { + const testClock = await stripeCli.testHelpers.testClocks.create({ + frozen_time: Math.floor(Date.now() / 1000), + }); + testClockId = testClock.id; + } + + // 1. Create stripe customer + const stripeCus = await stripeCli.customers.create({ + email, + name, + test_clock: testClockId, + }); + + // 2. Create customer + try { + await autumn.customers.delete(customerId); + } catch (error) {} + await autumn.customers.create({ + id: customerId, + name, + email, + fingerprint: fingerprint_, + // @ts-ignore + stripe_id: stripeCus.id, + }); + + // 3. Attach payment method + if (attachPm) { + await attachPaymentMethod({ + stripeCli, + stripeCusId: stripeCus.id, + type: attachPm, + }); + } + + return { + testClockId: testClockId || "", + }; +}; diff --git a/server/tests/advanced/defaultTrial/defaultTrial0.test.ts b/server/tests/advanced/defaultTrial/defaultTrial0.test.ts deleted file mode 100644 index 1445fa5be..000000000 --- a/server/tests/advanced/defaultTrial/defaultTrial0.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { APIVersion, ProductItemInterval, FreeTrialDuration } from "@autumn/shared"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { Organization, AppEnv } from "@autumn/shared"; -import { Stripe } from "stripe"; -import chalk from "chalk"; -import { setupBefore } from "tests/before.js"; -import { expect } from "chai"; -import { flipDefaultState, flipDefaultStates, manuallyAttachDefaultTrial } from "tests/utils/testAttachUtils/trialAttachUtils.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { TestFeature } from "tests/setup/v2Features.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import { addPrefixToProducts } from "tests/attach/utils.js"; -import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; - -const testCase = "defaultTrial0"; - -export let pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 1500, - interval: ProductItemInterval.Month, - }), - ], - // id: testCase + "_pro", - isDefault: true, - forcePaidDefault: true, - type: "pro", - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: false, - card_required: false, - }, -}); - -export let free = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 500, - interval: ProductItemInterval.Month, - }), - ], - type: "free", - isDefault: true, -}); - -const cleanUpCustomers = async (autumn: AutumnInt) => { - [testCase + "_a", testCase + "_b"].forEach(async (customerId) => { - await autumn.customers.delete(customerId).catch(e => { - throw e; - }); - }); -} - -describe(`${chalk.yellowBright(`advanced/${testCase}: ensure manually attaching is the same as creating a customer`)}`, () => { - - let customerId_a = testCase + "_a"; - let customerId_b = testCase + "_b"; - let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - let autumn_js: any; - - let curUnix = Math.floor(new Date().getTime() / 1000); - - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; - stripeCli = this.stripeCli; - autumn_js = this.autumnJs; - - - let productsToCreate = addPrefixToProducts({ - products: [pro, free], - prefix: testCase, - }); - - await createProducts({ - autumn, - products: productsToCreate, - db, - orgId: org.id, - env, - }).catch(e => { - if(e.message.includes("already exists")) { - return; - } - throw e; - }); - - await flipDefaultStates({ - currentCase: 0, - autumn, - }); - - await cleanUpCustomers(autumn); - }); - - it("should match initCustomer", async function () { - before(async function () { - await autumn.customers.delete(customerId_a); - await autumn.customers.delete(customerId_b); - }); - - await manuallyAttachDefaultTrial({ - customerId: customerId_a, - stripeCli, - autumn, - db, - org, - env, - autumnJs: autumn_js, - group: testCase, - }); - - let customer_a = await autumn.customers.get(customerId_a); - - expect(customer_a, "customer should be defined").to.exist; - - let customer_a_products = customer_a?.products.map(p => p.id + " " + p.status + " " + p.name); - - expect(customer_a_products[0], "customer_a_products should be defined").to.exist; - - await initCustomer({ - customerId: customerId_b, - autumn: autumn_js, - db, - org, - env, - }); - - let customer_b = await autumn.customers.get(customerId_b); - - let customer_b_products = customer_b?.products.map((p: any) => p.id + " " + p.status + " " + p.name); - - expect(customer_b_products[0], "customer_b_products should be defined").to.exist; - - expect(customer_a_products[0], "customer_a_products should be the same as customer_b_products").to.equal(customer_b_products[0]); - }); - - after(async function() { - await cleanUpCustomers(autumn); - }); -}) diff --git a/server/tests/advanced/defaultTrial/defaultTrial1.test.ts b/server/tests/advanced/defaultTrial/defaultTrial1.test.ts index 8cdad8140..1dcff6d14 100644 --- a/server/tests/advanced/defaultTrial/defaultTrial1.test.ts +++ b/server/tests/advanced/defaultTrial/defaultTrial1.test.ts @@ -1,28 +1,23 @@ import { AutumnInt } from "@/external/autumn/autumnCli.js"; // Manual customer creation - not using initCustomer to control test clock properly import { - APIVersion, - AppEnv, - CusProductStatus, - FreeTrialDuration, - Organization, - ProductItemInterval, + APIVersion, + AppEnv, + CusProductStatus, + Organization, } from "@autumn/shared"; import chalk from "chalk"; import Stripe from "stripe"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { setupBefore } from "tests/before.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import { addPrefixToProducts } from "tests/attach/utils.js"; -import { - constructFeatureItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { TestFeature } from "tests/setup/v2Features.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; -import { flipDefaultState, flipDefaultStates, manuallyAttachDefaultTrial } from "tests/utils/testAttachUtils/trialAttachUtils.js"; -import { expect } from "chai"; +import { + defaultTrialFree, + defaultTrialPro, + setupDefaultTrialBefore, +} from "./defaultTrialBefore.test.js"; +import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js"; // Case 1: ✅ // Pro product with default trial exists alongside a free default product @@ -35,124 +30,61 @@ import { expect } from "chai"; const testCase = "defaultTrial1"; -export let pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 1500, - interval: ProductItemInterval.Month, - }), - ], - // id: testCase + "_pro", - isDefault: true, - forcePaidDefault: true, - type: "pro", - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: false, - card_required: false, - }, -}); - -export let free = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 500, - interval: ProductItemInterval.Month, - }), - ], - type: "free", - isDefault: true, -}); - - describe(`${chalk.yellowBright(`advanced/${testCase}: ensure default trials are attached when creating a customer`)}`, () => { - let customerId = testCase; - let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); - let testClockID: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockID: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; - let curUnix = Math.floor(new Date().getTime() / 1000); + let curUnix = Math.floor(new Date().getTime() / 1000); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + before(async function () { + await setupBefore(this); + await setupDefaultTrialBefore({}); + const { autumnJs } = this; + stripeCli = this.stripeCli; + db = this.db; + org = this.org; + env = this.env; - stripeCli = this.stripeCli; - let testClock = await stripeCli.testHelpers.testClocks.create({ - frozen_time: curUnix, - }); - testClockID = testClock.id; - - let productsToCreate = addPrefixToProducts({ - products: [pro, free], - prefix: testCase, - }); - - await createProducts({ - autumn, - products: productsToCreate, - db, - orgId: org.id, - env, - }).catch(e => { - if(e.message.includes("already exists")) { - return; - } - throw e; - }); - - await flipDefaultStates({ - currentCase: 1, - autumn, - }); - - let customer = await manuallyAttachDefaultTrial({ - customerId, - stripeCli, - autumn, - db, - org, - env, - testClockID, - autumnJs, - group: testCase, - }); - - expect(customer, "customer should be defined").to.exist; + const res = await initCustomerV2({ + autumn: autumnJs, + customerId: testCase, + db, + org, + env, }); - it("should create a customer with the paid default trial", async function () { - let customer = await autumn.customers.get(customerId); + testClockID = res.testClockId; + }); - expectProductAttached({ - customer, - product: pro, - }); - }); + it("should create a customer with the paid default trial", async function () { + let customer = await autumn.customers.get(customerId); - describe("ensure trials automatically cancel if no payment method is provided", () => { - it("should expire after 7 days", async function () { - await advanceTestClock({ - stripeCli, - testClockId: testClockID, - numberOfDays: 8, - waitForSeconds: 10, - }); - - let customer = await autumn.customers.get(customerId); - - expectProductAttached({ - customer, - product: free, - status: CusProductStatus.Active - }); - }); + expectProductAttached({ + customer, + product: defaultTrialPro, + status: CusProductStatus.Trialing, }); + }); + + describe("ensure trials automatically cancel if no payment method is provided", () => { + it("should expire after 7 days", async function () { + await advanceTestClock({ + stripeCli, + testClockId: testClockID, + numberOfDays: 8, + waitForSeconds: 10, + }); + + let customer = await autumn.customers.get(customerId); + + expectProductAttached({ + customer, + product: defaultTrialFree, + status: CusProductStatus.Active, + }); + }); + }); }); diff --git a/server/tests/advanced/defaultTrial/defaultTrial2.test.ts b/server/tests/advanced/defaultTrial/defaultTrial2.test.ts index 5e4d2ea8f..5d55bb7bf 100644 --- a/server/tests/advanced/defaultTrial/defaultTrial2.test.ts +++ b/server/tests/advanced/defaultTrial/defaultTrial2.test.ts @@ -1,153 +1,88 @@ import { AutumnInt } from "@/external/autumn/autumnCli.js"; // Manual customer creation - not using initCustomer to control test clock properly import { - APIVersion, - AppEnv, - CusProductStatus, - FreeTrialDuration, - Organization, - ProductItemInterval, + APIVersion, + AppEnv, + CusProductStatus, + Organization, } from "@autumn/shared"; import chalk from "chalk"; import Stripe from "stripe"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { setupBefore } from "tests/before.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import { addPrefixToProducts } from "tests/attach/utils.js"; -import { - constructFeatureItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { TestFeature } from "tests/setup/v2Features.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; -import { flipDefaultState, flipDefaultStates, manuallyAttachDefaultTrial } from "tests/utils/testAttachUtils/trialAttachUtils.js"; -import { expect } from "chai"; -import { CusService } from "@/internal/customers/CusService.js"; -// 2.2: +import { + defaultTrialPro, + setupDefaultTrialBefore, +} from "./defaultTrialBefore.test.js"; +import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js"; +import { addDays, addHours } from "date-fns"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; + +// 2.2: // -> Creating a new customer with a payment method should attach the pro product with default trial // --> Advancing the test clock should cancel the trial and attach the pro product const testCase = "defaultTrial2"; -export let pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 1500, - interval: ProductItemInterval.Month, - }), - ], - // id: testCase + "_pro", - isDefault: true, - forcePaidDefault: true, - type: "pro", - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: false, - card_required: false, - }, -}); - -export let free = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 500, - interval: ProductItemInterval.Month, - }), - ], - type: "free", - isDefault: true, -}); - - describe(`${chalk.yellowBright(`advanced/${testCase}: ensure trial transitions into full product if payment method is valid`)}`, () => { - let customerId = testCase; - let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); - let testClockID: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockID: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; - let curUnix = Math.floor(new Date().getTime() / 1000); + let curUnix = Math.floor(new Date().getTime() / 1000); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + before(async function () { + await setupBefore(this); + await setupDefaultTrialBefore({}); + const { autumnJs } = this; + stripeCli = this.stripeCli; + db = this.db; + org = this.org; + env = this.env; - stripeCli = this.stripeCli; - let testClock = await stripeCli.testHelpers.testClocks.create({ - frozen_time: curUnix, - }); - testClockID = testClock.id; - - let productsToCreate = addPrefixToProducts({ - products: [pro, free], - prefix: testCase, - }); - - await createProducts({ - autumn, - products: productsToCreate, - db, - orgId: org.id, - env, - }).catch(e => { - if(e.message.includes("already exists")) { - return; - } - throw e; - }); - - await flipDefaultStates({ - currentCase: 2, - autumn, - }); - - let customer = await manuallyAttachDefaultTrial({ - customerId, - stripeCli, - autumn, - db, - org, - env, - testClockID, - autumnJs, - group: testCase, - attachPm: "success" - }); - - expect(customer, "customer should be defined").to.exist; + const res = await initCustomerV2({ + autumn: autumnJs, + customerId: testCase, + db, + org, + env, + attachPm: "success", }); - it("should create a customer with the paid default trial", async function () { - let customer = await autumn.customers.get(customerId); + testClockID = res.testClockId; + }); - expectProductAttached({ - customer, - product: pro, - }); + it("should create a customer with the paid default trial", async function () { + let customer = await autumn.customers.get(customerId); + + expectProductAttached({ + customer, + product: defaultTrialPro, + }); + }); + + it("should be active after 7 days", async function () { + await advanceTestClock({ + stripeCli, + testClockId: testClockID, + advanceTo: addHours( + addDays(new Date(), 7), + hoursToFinalizeInvoice + ).getTime(), + waitForSeconds: 10, }); - it("should be active after 7 days", async function () { - await advanceTestClock({ - stripeCli, - testClockId: testClockID, - numberOfDays: 9, - waitForSeconds: 10, - }); + let customer = await autumn.customers.get(customerId); - let customer = await autumn.customers.get(customerId); - - expectProductAttached({ - customer, - product: pro, - status: CusProductStatus.Active - }); + expectProductAttached({ + customer, + product: defaultTrialPro, + status: CusProductStatus.Active, }); + }); }); diff --git a/server/tests/advanced/defaultTrial/defaultTrial3.test.ts b/server/tests/advanced/defaultTrial/defaultTrial3.test.ts index ecbd934ca..42b63f203 100644 --- a/server/tests/advanced/defaultTrial/defaultTrial3.test.ts +++ b/server/tests/advanced/defaultTrial/defaultTrial3.test.ts @@ -1,28 +1,26 @@ import { AutumnInt } from "@/external/autumn/autumnCli.js"; // Manual customer creation - not using initCustomer to control test clock properly import { - APIVersion, - AppEnv, - CusProductStatus, - FreeTrialDuration, - Organization, - ProductItemInterval, + APIVersion, + AppEnv, + CusProductStatus, + Organization, } from "@autumn/shared"; import chalk from "chalk"; import Stripe from "stripe"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { setupBefore } from "tests/before.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import { addPrefixToProducts } from "tests/attach/utils.js"; -import { - constructFeatureItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { TestFeature } from "tests/setup/v2Features.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; -import { flipDefaultState, flipDefaultStates, manuallyAttachDefaultTrial } from "tests/utils/testAttachUtils/trialAttachUtils.js"; -import { expect } from "chai"; + +import { + defaultTrialFree, + defaultTrialPro, + setupDefaultTrialBefore, +} from "./defaultTrialBefore.test.js"; +import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js"; +import { addDays, addHours } from "date-fns"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; // 2.3: // -> Creating a new customer with a fake payment method should attach the pro product with default trial @@ -30,138 +28,78 @@ import { expect } from "chai"; const testCase = "defaultTrial3"; -export let pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 1500, - interval: ProductItemInterval.Month, - }), - ], - isDefault: true, - forcePaidDefault: true, - type: "pro", - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: false, - card_required: false, - }, -}); - -export let free = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 500, - interval: ProductItemInterval.Month, - }), - ], - type: "free", - isDefault: true, -}); - - describe(`${chalk.yellowBright(`advanced/${testCase}: ensure trials cancel with bad payment method`)}`, () => { - let customerId = testCase; - let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); - let testClockID: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockID: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; - let curUnix = Math.floor(new Date().getTime() / 1000); + let curUnix = Math.floor(new Date().getTime() / 1000); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + before(async function () { + await setupBefore(this); + await setupDefaultTrialBefore({}); + const { autumnJs } = this; + stripeCli = this.stripeCli; + db = this.db; + org = this.org; + env = this.env; - stripeCli = this.stripeCli; - let testClock = await stripeCli.testHelpers.testClocks.create({ - frozen_time: curUnix, - }); - testClockID = testClock.id; - - let productsToCreate = addPrefixToProducts({ - products: [pro, free], - prefix: testCase, - }); - - await createProducts({ - autumn, - products: productsToCreate, - db, - orgId: org.id, - env, - }).catch(e => { - if(e.message.includes("already exists")) { - return; - } - throw e; - }); - - await flipDefaultStates({ - currentCase: 3, - autumn, - }); - - let customer = await manuallyAttachDefaultTrial({ - customerId, - stripeCli, - autumn, - db, - org, - env, - testClockID, - autumnJs, - group: testCase, - attachPm: "fail" - }); - - expect(customer, "customer should be defined").to.exist; + const res = await initCustomerV2({ + autumn: autumnJs, + customerId: testCase, + db, + org, + env, + attachPm: "fail", }); - it("should create a customer with the paid default trial", async function () { - let customer = await autumn.customers.get(customerId); + testClockID = res.testClockId; + }); - expectProductAttached({ - customer, - product: pro, - }); + it("should create a customer with the paid default trial", async function () { + let customer = await autumn.customers.get(customerId); + + expectProductAttached({ + customer, + product: defaultTrialPro, + }); + }); + + it("should cancel after 7 days", async function () { + await advanceTestClock({ + stripeCli, + testClockId: testClockID, + advanceTo: addHours( + addDays(new Date(), 7), + hoursToFinalizeInvoice + ).getTime(), + waitForSeconds: 30, }); - it("should cancel after 7 days", async function () { - await advanceTestClock({ - stripeCli, - testClockId: testClockID, - numberOfDays: 8, - waitForSeconds: 30, - }); + let customer = await autumn.customers.get(customerId); - let customer = await autumn.customers.get(customerId); - - expectProductAttached({ - customer, - product: pro, - status: CusProductStatus.PastDue - }); - - await advanceTestClock({ - stripeCli, - testClockId: testClockID, - // should be massive so the stripe smart retry works in all settings - numberOfDays: 31, - waitForSeconds: 30, - }); - - customer = await autumn.customers.get(customerId); - - expectProductAttached({ - customer, - product: free, - status: CusProductStatus.Active - }); + expectProductAttached({ + customer, + product: defaultTrialPro, + status: CusProductStatus.PastDue, }); + + // await advanceTestClock({ + // stripeCli, + // testClockId: testClockID, + // // should be massive so the stripe smart retry works in all settings + // numberOfDays: 31, + // waitForSeconds: 30, + // }); + + // customer = await autumn.customers.get(customerId); + + // expectProductAttached({ + // customer, + // product: defaultTrialFree, + // status: CusProductStatus.Active, + // }); + }); }); diff --git a/server/tests/advanced/defaultTrial/defaultTrialBefore.test.ts b/server/tests/advanced/defaultTrial/defaultTrialBefore.test.ts new file mode 100644 index 000000000..30c428728 --- /dev/null +++ b/server/tests/advanced/defaultTrial/defaultTrialBefore.test.ts @@ -0,0 +1,57 @@ +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { + APIVersion, + FreeTrialDuration, + ProductItemInterval, +} from "@autumn/shared"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; + +export let defaultTrialPro = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 1500, + interval: ProductItemInterval.Month, + }), + ], + isDefault: true, + forcePaidDefault: true, + id: "defaultTrial_pro", + group: "defaultTrial", + type: "pro", + freeTrial: { + length: 7, + duration: FreeTrialDuration.Day, + unique_fingerprint: false, + card_required: false, + }, +}); + +export let defaultTrialFree = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 500, + interval: ProductItemInterval.Month, + }), + ], + id: "defaultTrial_free", + group: "defaultTrial", + type: "free", + isDefault: true, +}); + +export const setupDefaultTrialBefore = async ({}: {}) => { + const autumn = new AutumnInt({ version: APIVersion.v1_2 }); + for (const product of [defaultTrialPro, defaultTrialFree]) { + let res = await autumn.products.get(product.id); + + if (res.code === "product_not_found") { + try { + await autumn.products.create(product); + } catch (error) {} + } + } +}; diff --git a/server/tests/attach/basic/basic2.ts b/server/tests/attach/basic/basic2.ts index 6e99007f8..48dbf5766 100644 --- a/server/tests/attach/basic/basic2.ts +++ b/server/tests/attach/basic/basic2.ts @@ -4,16 +4,11 @@ import { assert, expect } from "chai"; import chalk from "chalk"; import { setupBefore } from "tests/before.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; -import { features, products } from "tests/global.js"; +import { products } from "tests/global.js"; import { compareMainProduct } from "tests/utils/compare.js"; import { completeCheckoutForm } from "tests/utils/stripeUtils.js"; import { timeout } from "tests/utils/genUtils.js"; -const oneTimeQuantity = 2; -const oneTimePurchaseCount = 2; -const oneTimeOverrideQuantity = 4; -const monthlyQuantity = 2; - // UNCOMMENT FROM HERE const testCase = "basic2"; describe(`${chalk.yellowBright("basic2: Testing attach pro")}`, () => { diff --git a/server/tests/utils/expectUtils/expectProductAttached.ts b/server/tests/utils/expectUtils/expectProductAttached.ts index 8080ae00e..2b6f3331b 100644 --- a/server/tests/utils/expectUtils/expectProductAttached.ts +++ b/server/tests/utils/expectUtils/expectProductAttached.ts @@ -28,12 +28,12 @@ export const expectProductAttached = ({ if (status) { expect(productAttached?.status).to.equal( status, - `product ${product.id} should have status ${status}`, + `product ${product.id} should have status ${status}` ); } else { expect( productAttached?.status, - `product ${product.id} is not expired`, + `product ${product.id} is not expired` ).to.not.equal(CusProductStatus.Expired); } @@ -68,12 +68,12 @@ export const expectInvoicesCorrect = ({ expect(invoices![0].total).to.approximately( first.total, 0.01, - `invoice total is correct: ${first.total}`, + `invoice total is correct: ${first.total}` ); expect(invoices![0].product_ids).to.include( first.productId, - `invoice includes product ${first.productId}`, + `invoice includes product ${first.productId}` ); } catch (error) { console.log(`invoice for ${first.productId}, ${first.total} not found`); @@ -96,15 +96,15 @@ export const expectInvoicesCorrect = ({ expect(totalAmount).to.approximately( second.total, 0.01, - `first & second invoice total should sum to ${second.total}`, + `first & second invoice total should sum to ${second.total}` ); expect( invoices![0].product_ids.includes(second.productId), - `invoice 1 includes product ${second.productId}`, + `invoice 1 includes product ${second.productId}` ).to.be.true; expect( invoices![1].product_ids.includes(second.productId), - `invoice 2 includes product ${second.productId}`, + `invoice 2 includes product ${second.productId}` ).to.be.true; } catch (error) { console.log(`invoice for ${second.productId}, ${second.total} not found`); diff --git a/server/tests/utils/testAttachUtils/trialAttachUtils.ts b/server/tests/utils/testAttachUtils/trialAttachUtils.ts index b2d3e16c0..4ae39423e 100644 --- a/server/tests/utils/testAttachUtils/trialAttachUtils.ts +++ b/server/tests/utils/testAttachUtils/trialAttachUtils.ts @@ -11,224 +11,228 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { attachPmToCus } from "@/external/stripe/stripeCusUtils.js"; export async function manuallyAttachDefaultTrial({ - customerId, - stripeCli, - autumn, - db, - org, - env, - testClockID, - autumnJs, - attachPm = "", - group, + customerId, + stripeCli, + autumn, + db, + org, + env, + testClockID, + autumnJs, + attachPm = "", + group, }: { - customerId: string; - stripeCli: Stripe; - autumn: AutumnInt; - db: DrizzleCli; - org: any; - env: AppEnv; - testClockID?: string; - autumnJs: any; - attachPm?: "success" | "fail" | ""; - group?: string; + customerId: string; + stripeCli: Stripe; + autumn: AutumnInt; + db: DrizzleCli; + org: any; + env: AppEnv; + testClockID?: string; + autumnJs: any; + attachPm?: "success" | "fail" | ""; + group?: string; }) { - try { - const existingCustomer = await CusService.get({ db, idOrInternalId: customerId, orgId: org.id, env }); - if (existingCustomer) { - // Delete via API to clean up properly - await autumnJs.customers.delete(customerId); - } - } catch (error) { - // Ignore if customer doesn't exist - console.log("Customer doesn't exist, skipping delete", error); + try { + const existingCustomer = await CusService.get({ + db, + idOrInternalId: customerId, + orgId: org.id, + env, + }); + if (existingCustomer) { + // Delete via API to clean up properly + await autumnJs.customers.delete(customerId); } + } catch (error) { + // Ignore if customer doesn't exist + console.log("Customer doesn't exist, skipping delete", error); + } - // Step 2: Manually create customer in DB (following createNewCustomer.ts logic) - const customerData = { - id: customerId, - name: customerId, - email: `${customerId}@example.com`, - metadata: {}, - internal_id: generateId("cus"), - org_id: org.id, - created_at: Date.now(), - env, - }; + // Step 2: Manually create customer in DB (following createNewCustomer.ts logic) + const customerData = { + id: customerId, + name: customerId, + email: `${customerId}@example.com`, + metadata: {}, + internal_id: generateId("cus"), + org_id: org.id, + created_at: Date.now(), + env, + }; - const newCustomer = await CusService.insert({ - db, - data: customerData, + const newCustomer = await CusService.insert({ + db, + data: customerData, + }); + + if (!newCustomer) { + throw new Error("Failed to create customer"); + } + + // Step 3: Get default products (following createNewCustomer.ts logic) + const allDefaultProds = await ProductService.listDefault({ + db, + orgId: org.id, + env, + }); + + // Filter by group if specified + const defaultProds = group + ? allDefaultProds.filter((p) => p.group === group) + : allDefaultProds; + + const defaultPaidTrialProd = defaultProds.find((p) => + isDefaultTrialFullProduct({ product: p }) + ); + + let customer = newCustomer; + + if (defaultPaidTrialProd) { + // Step 4: Create Stripe customer with test clock + const stripeCustomer = await stripeCli.customers.create({ + email: `${customerId}@example.com`, + test_clock: testClockID ? testClockID : undefined, }); - if (!newCustomer) { - throw new Error("Failed to create customer"); - } - - // Step 3: Get default products (following createNewCustomer.ts logic) - const allDefaultProds = await ProductService.listDefault({ - db, - orgId: org.id, - env, + // Step 5: Update customer with Stripe processor info (BEFORE attachPmToCus) + await CusService.update({ + db, + internalCusId: newCustomer.internal_id, + update: { + processor: { + type: ProcessorType.Stripe, + id: stripeCustomer.id, + }, + }, }); - // Filter by group if specified - const defaultProds = group - ? allDefaultProds.filter(p => p.group === group) - : allDefaultProds; + // Update local customer object + customer = { + ...newCustomer, + processor: { + id: stripeCustomer.id, + type: "stripe", + }, + } as any; - const defaultPaidTrialProd = defaultProds.find((p) => - isDefaultTrialFullProduct({ product: p }) - ); - - let customer = newCustomer; - - if (defaultPaidTrialProd) { - // Step 4: Create Stripe customer with test clock - const stripeCustomer = await stripeCli.customers.create({ - email: `${customerId}@example.com`, - test_clock: testClockID ? testClockID : undefined, - }); - - // Step 5: Update customer with Stripe processor info (BEFORE attachPmToCus) - await CusService.update({ - db, - internalCusId: newCustomer.internal_id, - update: { - processor: { - type: ProcessorType.Stripe, - id: stripeCustomer.id, - }, - }, - }); - - // Update local customer object - customer = { - ...newCustomer, - processor: { - id: stripeCustomer.id, - type: "stripe", - }, - } as any; - - if (attachPm && testClockID) { - await attachPmToCus({ - customer: customer, - org: org, - env: env, - db: db, - testClockId: testClockID, - willFail: attachPm === "fail", - }); - } - - // Step 6: Manually attach the default trial product (following createNewCustomer.ts logic) - const req = { - db, - org, - env, - orgId: org.id, - logtail: console, - logger: console, - } as any; - - await handleAddProduct({ - req, - attachParams: newCusToAttachParams({ - req, - newCus: customer as any, - products: [defaultPaidTrialProd], - stripeCli, - freeTrial: defaultPaidTrialProd.free_trial || null, - }), - }); - - return customer; + if (attachPm && testClockID) { + await attachPmToCus({ + customer: customer, + org: org, + env: env, + db: db, + testClockId: testClockID, + willFail: attachPm === "fail", + }); } + + // Step 6: Manually attach the default trial product (following createNewCustomer.ts logic) + const req = { + db, + org, + env, + orgId: org.id, + logtail: console, + logger: console, + } as any; + + await handleAddProduct({ + req, + attachParams: newCusToAttachParams({ + req, + newCus: customer as any, + products: [defaultPaidTrialProd], + stripeCli, + freeTrial: defaultPaidTrialProd.free_trial || null, + }), + }); + + return customer; + } } export async function cleanupQueueAndCache() { - try { - const { QueueManager } = await import("@/queue/QueueManager.js"); - const queueInstance = await QueueManager.getInstance(); - - // Access private properties to close connections - if ((queueInstance as any).queue) { - await (queueInstance as any).queue.close(); - } - if ((queueInstance as any).backupQueue) { - await (queueInstance as any).backupQueue.close(); - } - if ((queueInstance as any).mainConnection) { - await (queueInstance as any).mainConnection.quit(); - } - if ((queueInstance as any).backupConnection) { - await (queueInstance as any).backupConnection.quit(); - } - } catch (error) { - // Ignore cleanup errors - } + try { + const { QueueManager } = await import("@/queue/QueueManager.js"); + const queueInstance = await QueueManager.getInstance(); - try { - const { CacheManager } = await import("@/external/caching/CacheManager.js"); - const cacheInstance = await CacheManager.getInstance(); - if ((cacheInstance as any).connection) { - await (cacheInstance as any).connection.quit(); - } - } catch (error) { - // Ignore cleanup errors + // Access private properties to close connections + if ((queueInstance as any).queue) { + await (queueInstance as any).queue.close(); } + if ((queueInstance as any).backupQueue) { + await (queueInstance as any).backupQueue.close(); + } + if ((queueInstance as any).mainConnection) { + await (queueInstance as any).mainConnection.quit(); + } + if ((queueInstance as any).backupConnection) { + await (queueInstance as any).backupConnection.quit(); + } + } catch (error) { + // Ignore cleanup errors + } + + try { + const { CacheManager } = await import("@/external/caching/CacheManager.js"); + const cacheInstance = await CacheManager.getInstance(); + if ((cacheInstance as any).connection) { + await (cacheInstance as any).connection.quit(); + } + } catch (error) { + // Ignore cleanup errors + } } export async function flipDefaultState({ - id, - autumn, - state + id, + autumn, + state, }: { - id: string; - autumn: AutumnInt; - state: boolean; + id: string; + autumn: AutumnInt; + state: boolean; }) { - try { - let productExists = await autumn.products.get(id); - if (productExists) { - await autumn.products.update(id, { - is_default: state, - }); - } - } catch (error) { - // Ignore if product doesn't exist - console.log("Product doesn't exist, skipping update", error); + try { + let productExists = await autumn.products.get(id); + if (productExists) { + await autumn.products.update(id, { + is_default: state, + }); } + } catch (error) { + console.log(`Product ${id} doesn't exist, skipping update`); + } } export async function flipDefaultStates({ - currentCase, - autumn, + currentCase, + autumn, }: { - currentCase: number; - autumn: AutumnInt; + currentCase: number; + autumn: AutumnInt; }) { - let total = 4; - - // Now flip all products from 0 to total-1, only current case should be true - for (let i = 0; i < total; i++) { - const id = `defaultTrial${i}_pro`; - const state = i === currentCase; // Only the current case is true - await flipDefaultState({ - id, - autumn, - state, - }); - } + let total = 4; - for (let i = 0; i < total; i++) { - const id = `defaultTrial${i}_free`; - const state = i === currentCase; // Only the current case is true - await flipDefaultState({ - id, - autumn, - state, - }); - } -} \ No newline at end of file + // Now flip all products from 0 to total-1, only current case should be true + for (let i = 0; i < total; i++) { + const id = `defaultTrial${i}_pro`; + const state = i === currentCase; // Only the current case is true + await flipDefaultState({ + id, + autumn, + state, + }); + } + + for (let i = 0; i < total; i++) { + const id = `defaultTrial${i}_free`; + const state = i === currentCase; // Only the current case is true + await flipDefaultState({ + id, + autumn, + state, + }); + } +} diff --git a/shared/models/productModels/freeTrialModels/freeTrialModels.ts b/shared/models/productModels/freeTrialModels/freeTrialModels.ts index f23ce091f..6dde22ea3 100644 --- a/shared/models/productModels/freeTrialModels/freeTrialModels.ts +++ b/shared/models/productModels/freeTrialModels/freeTrialModels.ts @@ -29,7 +29,7 @@ export const FreeTrialResponseSchema = z.object({ length: z.number(), unique_fingerprint: z.boolean(), trial_available: z.boolean().nullish().default(true), - card_required: z.boolean(), + card_required: z.boolean().nullish(), }); export type FreeTrial = z.infer; diff --git a/vite/src/views/customers/CustomersView.tsx b/vite/src/views/customers/CustomersView.tsx index 1635154ec..31a3cc2e0 100644 --- a/vite/src/views/customers/CustomersView.tsx +++ b/vite/src/views/customers/CustomersView.tsx @@ -16,15 +16,9 @@ import CreateCustomer from "./CreateCustomer"; import { SearchBar } from "./SearchBar"; import LoadingScreen from "../general/LoadingScreen"; import FilterButton from "./FilterButton"; -import { SavedViewsDropdown } from "./SavedViewsDropdown"; + import SmallSpinner from "@/components/general/SmallSpinner"; -import { - useQueryStates, - parseAsString, - parseAsInteger, - parseAsJson, - parseAsArrayOf, -} from "nuqs"; +import { useQueryStates, parseAsString, parseAsInteger } from "nuqs"; function CustomersView({ env }: { env: AppEnv }) { const pageSize = 50; diff --git a/vite/src/views/onboarding2/model-pricing/EditProduct.tsx b/vite/src/views/onboarding2/model-pricing/EditProduct.tsx index 419671735..059c17431 100644 --- a/vite/src/views/onboarding2/model-pricing/EditProduct.tsx +++ b/vite/src/views/onboarding2/model-pricing/EditProduct.tsx @@ -22,6 +22,7 @@ import { Product } from "@autumn/shared"; import { updateProduct } from "@/views/products/product/utils/updateProduct"; import { getBackendErr } from "@/utils/genUtils"; import { EditProductDetails } from "./edit-product/EditProductDetails"; +import { ToggleDefaultProduct } from "@/views/products/product/product-sidebar/ToggleDefaultProduct"; export const EditProduct = ({ mutate }: { mutate: any }) => { const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false); @@ -116,6 +117,7 @@ export const EditProduct = ({ mutate }: { mutate: any }) => { > { >
- handleToggleSettings("is_default")} - /> + /> */} +
+

Default Product

+ +
A default product is enabled by default for all new users, typically used for your free plan.
- handleToggleSettings("is_add_on")} - /> + /> */} +
+

Add On Product

+ +
A product that can be added on top of a customer's main plan. Eg. one time purchases or top ups. diff --git a/vite/src/views/products/CreateProduct.tsx b/vite/src/views/products/CreateProduct.tsx index b2baa1b15..74b54ad19 100644 --- a/vite/src/views/products/CreateProduct.tsx +++ b/vite/src/views/products/CreateProduct.tsx @@ -24,6 +24,7 @@ import { getBackendErr, navigateTo } from "@/utils/genUtils"; import { ProductConfig } from "./ProductConfig"; import { ProductV2 } from "@autumn/shared"; import { ToggleButton } from "@/components/general/ToggleButton"; +import { WarningBox } from "@/components/general/modal-components/WarningBox"; export const defaultProduct = { name: "", @@ -38,7 +39,7 @@ function CreateProduct({ }: { onSuccess?: (newProduct: ProductV2) => Promise; }) { - const { env, mutate } = useProductsContext(); + const { env, mutate, groupToDefaults } = useProductsContext(); const [loading, setLoading] = useState(false); const [product, setProduct] = useState(defaultProduct); const [open, setOpen] = useState(false); @@ -74,6 +75,8 @@ function CreateProduct({ } }, [open]); + const groupDefault = groupToDefaults[product.group]?.free; + return ( @@ -89,8 +92,15 @@ function CreateProduct({ isUpdate={false} /> + {groupDefault && product.is_default && ( + + Creating this product will disable default on {groupDefault.name}{" "} + and enable it on this product. + + )} + -
+
{ - const { product, setProduct, counts, mutate } = useProductContext(); - const axiosInstance = useAxiosInstance(); - const [defaultOpen, setDefaultOpen] = React.useState(false); - const [defaultTrialOpen, setDefaultTrialOpen] = React.useState(false); - const [addOnOpen, setAddOnOpen] = React.useState(false); - const [groupModalOpen, setGroupModalOpen] = React.useState(false); - const [tempGroup, setTempGroup] = React.useState(product.group || ""); - const [archivedOpen, setArchivedOpen] = React.useState(false); + const { product, setProduct, counts, mutate } = useProductContext(); + const axiosInstance = useAxiosInstance(); + const [defaultOpen, setDefaultOpen] = React.useState(false); + const [defaultTrialOpen, setDefaultTrialOpen] = React.useState(false); + const [addOnOpen, setAddOnOpen] = React.useState(false); + const [groupModalOpen, setGroupModalOpen] = React.useState(false); + const [tempGroup, setTempGroup] = React.useState(product.group || ""); + const [archivedOpen, setArchivedOpen] = React.useState(false); - return ( - <> -
-
-
-

- Product ID -

- - {product.id} - -
-
-

- Customers -

- - -

- {counts?.active ?? 0} active -

-
- +
+
+

+ Product ID +

+ + {product.id} + +
+
+

Customers

+ + +

+ {counts?.active ?? 0} active +

+
+ -

- Canceled: {counts?.canceled} -

- {counts?.trialing > 0 && ( -

- Trialing:{" "} - {counts?.trialing} -

- )} - {counts?.custom > 0 && ( -

- Custom: {counts?.custom} -

- )} -
-
-
-
-

- Default -

- - - - - -
- - -
-
-
-
+ side="bottom" + sideOffset={4} + > +

+ Canceled: {counts?.canceled} +

+ {counts?.trialing > 0 && ( +

+ Trialing: {counts?.trialing} +

+ )} + {counts?.custom > 0 && ( +

+ Custom: {counts?.custom} +

+ )} + + +
+
+
+

Default

+ +

+ Default products are the default product for a group. They are + used to determine the default product for a customer when they + don't have an active subscription. +

+
+
+ +
+
+
+

Add On

+ +

+ Add-ons are products that are added to a customer's + subscription. They are used to determine the default product + for a customer when they don't have an active subscription. +

+
+
+ +
-
-

- Add On -

- - - - - -
- - -
-
-
-
+
+

Group

+ +
-
-

- Group -

- -
+ + + + Edit Product Group + +

+ Assign this product to a group. Customers will be able to have + active subscriptions from different product groups at the same + time. This can alter your existing upgrade and downgrade logic, + so read the docs{" "} + + here + {" "} + to understand how this works. +

+
+ setTempGroup(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setProduct({ + ...product, + group: tempGroup, + }); + setGroupModalOpen(false); + } + }} + /> +
+ +
+
+
+
- - - - Edit Product Group - -

- Assign this product to a group. Customers will - be able to have active subscriptions from - different product groups at the same time. This - can alter your existing upgrade and downgrade - logic, so read the docs{" "} - - here - {" "} - to understand how this works. -

-
- - setTempGroup(e.target.value) - } - onKeyDown={(e) => { - if (e.key === "Enter") { - setProduct({ - ...product, - group: tempGroup, - }); - setGroupModalOpen(false); - } - }} - /> -
- -
-
-
-
- -
-

- Archived -

- - - - - {/** This will not use the setProduct function because otherwise it will create a new - * version of the product. - */} - -
- - -
-
-
-
-
-
- - ); +
+

Archived

+
+ { + try { + await ProductService.updateProduct( + axiosInstance, + product.id, + { archived: value }, + product.version + ); + await mutate(); + toast.success( + value + ? "Product archived successfully" + : "Product unarchived successfully" + ); + } catch (error) { + toast.error( + getBackendErr(error, "Failed to archive product") + ); + } + }} + /> +
+
+
+
+ + ); }; diff --git a/vite/src/views/products/product/ProductSidebar.tsx b/vite/src/views/products/product/ProductSidebar.tsx index 953dafa7a..09bc5944a 100644 --- a/vite/src/views/products/product/ProductSidebar.tsx +++ b/vite/src/views/products/product/ProductSidebar.tsx @@ -12,10 +12,9 @@ import { AttachButton } from "@/views/customers/customer/product/components/Atta import { CustomerProductBadge } from "@/views/customers/customer/product/components/CustomerProductBadge"; import { EntitiesSidebar } from "./product-item/EntitiesSidebar"; import { UpdateProductButton } from "./components/UpdateProductButton"; -import { toast } from "sonner"; export default function ProductSidebar() { - const { product, org, setProduct, customer, features, mutate } = useProductContext(); + const { product, setProduct, customer } = useProductContext(); const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false); const [entitiesOpen, setEntitiesOpen] = useState(false); const [accordionValues, setAccordionValues] = useState([ @@ -36,7 +35,7 @@ export default function ProductSidebar() { const handleAccordionToggle = (value: string) => { setAccordionValues((prev) => - prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value], + prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value] ); }; diff --git a/vite/src/views/products/product/ProductVersions.tsx b/vite/src/views/products/product/ProductVersions.tsx index 86e3d9912..133d5e445 100644 --- a/vite/src/views/products/product/ProductVersions.tsx +++ b/vite/src/views/products/product/ProductVersions.tsx @@ -41,13 +41,13 @@ export const ProductVersions = () => { `${customer ? `/customers/${customer.id}` : "/products"}/${ product.id }?version=${value}`, - env, - ), + env + ) ); }} > @@ -68,8 +68,8 @@ export const ProductVersions = () => { ? `/customers/${customer.id}` : "/products" }/${product.id}?version=${version}`, - env, - ), + env + ) ); }} > diff --git a/vite/src/views/products/product/free-trial/CreateFreeTrial.tsx b/vite/src/views/products/product/free-trial/CreateFreeTrial.tsx index 692f8c731..02979ce5c 100644 --- a/vite/src/views/products/product/free-trial/CreateFreeTrial.tsx +++ b/vite/src/views/products/product/free-trial/CreateFreeTrial.tsx @@ -29,7 +29,7 @@ export const CreateFreeTrial = ({ length: 7, unique_fingerprint: false, duration: FreeTrialDuration.Day, - card_required: false, + card_required: true, }); const handleCreateFreeTrial = async () => { diff --git a/vite/src/views/products/product/free-trial/FreeTrialConfig.tsx b/vite/src/views/products/product/free-trial/FreeTrialConfig.tsx index b33645a67..75cb4b9c7 100644 --- a/vite/src/views/products/product/free-trial/FreeTrialConfig.tsx +++ b/vite/src/views/products/product/free-trial/FreeTrialConfig.tsx @@ -23,7 +23,7 @@ export const FreeTrialConfig = ({ length: freeTrial?.length || 7, unique_fingerprint: freeTrial?.unique_fingerprint || false, duration: freeTrial?.duration || FreeTrialDuration.Day, - card_required: freeTrial?.card_required ?? false, + card_required: freeTrial?.card_required ?? true, }); useEffect(() => { @@ -78,7 +78,7 @@ export const FreeTrialConfig = ({ fingerprint

- +
void; + description: string; + toggleKey: "is_default" | "is_add_on"; + value: boolean; + toggleProduct: (value: boolean, optimisticUpdate?: boolean) => Promise; +}) => { + const { product, customer } = useProductContext(); + const [loading, setLoading] = useState(false); + const handleConfirm = async () => { + setLoading(true); + try { + await toggleProduct(value, false); + setOpen(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to update product")); + } + setLoading(false); + }; + + const getTitle = () => { + if (toggleKey === "is_default") { + return value + ? `Make ${product.name} a default product` + : `Remove default from ${product.name}`; + } else { + return value + ? `Make ${product.name} an add-on` + : `Remove ${product.name} as an add-on`; + } + }; + return ( + + + + {getTitle()} + + +

{description}

+
+ + + +
+
+ ); +}; + +export const ToggleDefaultProduct = ({ + toggleKey, +}: { + toggleKey: "is_default" | "is_add_on"; +}) => { + const axiosInstance = useAxiosInstance(); + const { product, setProduct, counts, mutate, customer, groupDefaults } = + useProductContext(); + + const activeCount = counts?.active; + const [open, setOpen] = useState(false); + const [dialogDescription, setDialogDescription] = useState(""); + const [toggling, setToggling] = useState(false); + + const toggleProduct = async (value: boolean, optimisticUpdate = true) => { + setToggling(true); + + if (toggling) { + return; + } + + try { + if (optimisticUpdate) { + setProduct({ + ...product, + [toggleKey]: value, + }); + } + + const data = { + [toggleKey]: value, + }; + + await ProductService.updateProduct(axiosInstance, product.id, data); + mutate(); + setOpen(false); + toast.success("Successfully updated product"); + } catch (error) { + setProduct({ + ...product, + [toggleKey]: !value, + }); + + toast.error(getBackendErr(error, "Failed to update product")); + } finally { + setToggling(false); + } + }; + + const handleToggle = async (value: boolean) => { + if (toggling) return; + + const disableDefaultDescription = getDisableDefaultDescription(value); + if (disableDefaultDescription) { + setDialogDescription(disableDefaultDescription); + setOpen(true); + return; + } + + if (activeCount > 0) { + const activeCusStr = activeCount === 1 ? "customer" : "customers"; + // 1. If key is default + if (toggleKey === "is_default") { + if (value) { + setDialogDescription( + `You have ${activeCount} active ${activeCusStr} on this product. Are you sure you want to make this product default?` + ); + } else { + setDialogDescription( + `You have ${activeCount} active ${activeCusStr} on this product. Are you sure you want to remove this product as default?` + ); + } + } else { + if (value) { + setDialogDescription( + `You have ${activeCount} active ${activeCusStr} on this product. Are you sure you want to make this product an add-on?` + ); + } else { + setDialogDescription( + `You have ${activeCount} active ${activeCusStr} on this product. Are you sure you want to remove this product as an add-on?` + ); + } + } + setOpen(true); + } else { + await toggleProduct(value); + } + }; + + const getDisableDefaultDescription = (value: boolean) => { + // 1. Is default trial + if (toggleKey !== "is_default") return; + + const isDefaultTrial = + value && product.free_trial && !isFreeProductV2(product); + + if (isDefaultTrial && notNullish(groupDefaults?.defaultTrial)) { + return `${groupDefaults.defaultTrial.name} is currently a default trial product. Making ${product.name} a default trial will remove ${groupDefaults.defaultTrial.name} as a default trial product.`; + } + + if (value && notNullish(groupDefaults?.free)) { + return `${groupDefaults.free.name} is currently a default product. Making ${product.name} a default product will remove ${groupDefaults.free.name} as a default product.`; + } + }; + + const isDisabled = + (toggleKey === "is_add_on" && product.is_default) || + (toggleKey === "is_default" && product.is_add_on); + + return ( + <> + + + + ); +};