From cd905d266da3288dec0f5ae80fd1f746995ae697 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 26 Jun 2025 13:57:36 +0100 Subject: [PATCH] fix: returning trial available in getProductCheckPreview --- .../api/components/componentRouter.ts | 94 +++++++++++-------- .../handlers/getProductCheckPreview.ts | 10 +- .../entitled/handlers/handleProductCheck.ts | 10 -- .../addProductFlow/handleOneOffFunction.ts | 59 +++++++----- .../products/free-trials/freeTrialUtils.ts | 12 +-- .../internal/products/pricecn/pricecnUtils.ts | 33 ++++++- .../productUtils/detectProductVariant.ts | 5 +- .../src/internal/products/productV2Utils.ts | 12 +++ .../freeTrialModels/freeTrialModels.ts | 1 + 9 files changed, 148 insertions(+), 88 deletions(-) diff --git a/server/src/internal/api/components/componentRouter.ts b/server/src/internal/api/components/componentRouter.ts index 980d8a474..b0523aaac 100644 --- a/server/src/internal/api/components/componentRouter.ts +++ b/server/src/internal/api/components/componentRouter.ts @@ -28,9 +28,9 @@ componentRouter.get("/pricing_table", async (req: any, res) => ProductService.listFull({ db, orgId, env }), (async () => { if (!customerId) { - return null; + return undefined; } - return await CusService.get({ + return await CusService.getFull({ db, orgId, env, @@ -59,48 +59,66 @@ componentRouter.get("/pricing_table", async (req: any, res) => } }); - let cusProducts: FullCusProduct[] | null = null; + let batchResponse = []; + for (let p of products) { + let prod = getProductResponse({ product: p, features }); + let curMainProduct, curScheduledProduct; - if (customer) { - cusProducts = await CusProductService.list({ - db, - internalCustomerId: customer.internal_id, - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.PastDue, - CusProductStatus.Scheduled, - ], - }); + if (customer) { + let res = getExistingCusProducts({ + product: p, + cusProducts: customer.customer_products, + }); + + curMainProduct = res.curMainProduct; + curScheduledProduct = res.curScheduledProduct; + } + + batchResponse.push( + toPricecnProduct({ + db, + org, + product: prod as ProductV2, + fullProduct: p, + features, + curMainProduct, + curScheduledProduct, + otherProducts: products.filter((other) => other.id != p.id), + fullCus: customer, + }), + ); } - let pricecnProds = await Promise.all( - products - // .filter((p) => !p.is_add_on) - .map(async (p) => { - let prod = getProductResponse({ product: p, features }); - let curMainProduct, curScheduledProduct; + let pricecnProds = await Promise.all(batchResponse); - if (cusProducts) { - let res = getExistingCusProducts({ - product: p, - cusProducts: cusProducts, - }); + // let pricecnProds = await Promise.all( + // products + // // .filter((p) => !p.is_add_on) + // .map(async (p) => { + // let prod = getProductResponse({ product: p, features }); + // let curMainProduct, curScheduledProduct; - curMainProduct = res.curMainProduct; - curScheduledProduct = res.curScheduledProduct; - } + // if (cusProducts) { + // let res = getExistingCusProducts({ + // product: p, + // cusProducts: cusProducts, + // }); - return toPricecnProduct({ - org, - product: prod as ProductV2, - fullProduct: p, - features, - curMainProduct, - curScheduledProduct, - otherProducts: products.filter((other) => other.id != p.id), - }); - }), - ); + // curMainProduct = res.curMainProduct; + // curScheduledProduct = res.curScheduledProduct; + // } + + // return toPricecnProduct({ + // org, + // product: prod as ProductV2, + // fullProduct: p, + // features, + // curMainProduct, + // curScheduledProduct, + // otherProducts: products.filter((other) => other.id != p.id), + // }); + // }), + // ); res.status(200).json({ list: pricecnProds, diff --git a/server/src/internal/api/entitled/handlers/getProductCheckPreview.ts b/server/src/internal/api/entitled/handlers/getProductCheckPreview.ts index 8634cb93c..86fbcb566 100644 --- a/server/src/internal/api/entitled/handlers/getProductCheckPreview.ts +++ b/server/src/internal/api/entitled/handlers/getProductCheckPreview.ts @@ -18,6 +18,7 @@ import { isOneOff } from "@/internal/products/productUtils.js"; import { formatAmount } from "@/utils/formatUtils.js"; import { Decimal } from "decimal.js"; import { notNullish } from "@/utils/genUtils.js"; +import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; const getNextCycle = (preview: AttachPreview) => { if (!preview.due_next_cycle && !preview.due_today) { @@ -29,11 +30,13 @@ const getNextCycle = (preview: AttachPreview) => { }; export const attachToCheckPreview = async ({ preview, + params, product, org, features, }: { preview: AttachPreview; + params: AttachParams; product: FullProduct; org: Organization; features: Feature[]; @@ -112,7 +115,11 @@ export const attachToCheckPreview = async ({ items, due_today, due_next_cycle, - product: getProductResponse({ product, features }), + product: getProductResponse({ + product, + features, + trialAvailable: notNullish(params.freeTrial) ? true : false, + }), }; return checkPreview; }; @@ -153,6 +160,7 @@ export const getProductCheckPreview = async ({ const checkPreview = await attachToCheckPreview({ preview, + params: attachParams, product, org, features, diff --git a/server/src/internal/api/entitled/handlers/handleProductCheck.ts b/server/src/internal/api/entitled/handlers/handleProductCheck.ts index 10298d876..b1db362c8 100644 --- a/server/src/internal/api/entitled/handlers/handleProductCheck.ts +++ b/server/src/internal/api/entitled/handlers/handleProductCheck.ts @@ -84,16 +84,6 @@ export const handleProductCheck = async ({ // }) // : undefined; - if (preview) { - preview = { - ...preview, - product: getProductResponse({ - product: product!, - features, - }), - }; - } - if (!cusProduct) { res.status(200).json({ customer_id, diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index fa23436f8..96e4cb92a 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -14,6 +14,7 @@ import { AttachConfig } from "@autumn/shared"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; import { Decimal } from "decimal.js"; +import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; export const handleOneOffFunction = async ({ req, @@ -56,32 +57,46 @@ export const handleOneOffFunction = async ({ .toNumber(); } - const amount = priceToInvoiceAmount({ - price, - quantity, - }); + let invoiceItemData = {}; + if (isFixedPrice({ price })) { + quantity = 1; - const product = priceToProduct({ - price, - products, - }); + invoiceItemData = { + price: price.config.stripe_price_id, + quantity: 1, + }; + } else { + const amount = priceToInvoiceAmount({ + price, + quantity, + }); - const description = newPriceToInvoiceDescription({ - org, - price, - product: product!, - ents: entitlements, - quantity: options?.quantity, - withProductPrefix: true, - }); + const product = priceToProduct({ + price, + products, + }); + + const description = newPriceToInvoiceDescription({ + org, + price, + product: product!, + ents: entitlements, + quantity: options?.quantity, + withProductPrefix: true, + }); + + invoiceItemData = { + description, + price_data: { + unit_amount: new Decimal(amount).mul(100).round().toNumber(), + currency: org.default_currency, + product: price.config?.stripe_product_id || product?.processor?.id!, + }, + }; + } invoiceItems.push({ - description, - price_data: { - unit_amount: new Decimal(amount).mul(100).round().toNumber(), - currency: org.default_currency, - product: price.config?.stripe_product_id || product?.processor?.id!, - }, + ...invoiceItemData, quantity: 1, }); } diff --git a/server/src/internal/products/free-trials/freeTrialUtils.ts b/server/src/internal/products/free-trials/freeTrialUtils.ts index b1a782360..3869969b4 100644 --- a/server/src/internal/products/free-trials/freeTrialUtils.ts +++ b/server/src/internal/products/free-trials/freeTrialUtils.ts @@ -5,18 +5,8 @@ import { CreateFreeTrialSchema, FreeTrial, FreeTrialDuration, - FullProduct, } from "@autumn/shared"; -import { SupabaseClient } from "@supabase/supabase-js"; -import { - addDays, - addMinutes, - addMonths, - addSeconds, - addWeeks, - addYears, - getTime, -} from "date-fns"; +import { addDays, addMinutes, addMonths, addYears } from "date-fns"; import { FreeTrialService } from "./FreeTrialService.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; diff --git a/server/src/internal/products/pricecn/pricecnUtils.ts b/server/src/internal/products/pricecn/pricecnUtils.ts index c5a20002f..68d37113e 100644 --- a/server/src/internal/products/pricecn/pricecnUtils.ts +++ b/server/src/internal/products/pricecn/pricecnUtils.ts @@ -15,12 +15,16 @@ import { numberWithCommas, AttachScenario, FullProduct, + FullCustomer, + FreeTrialResponseSchema, } from "@autumn/shared"; import { isPriceItem } from "../product-items/productItemUtils/getItemType.js"; import { isFeaturePriceItem } from "../product-items/productItemUtils/getItemType.js"; import { cusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { isProductUpgrade } from "../productUtils.js"; import { getFirstInterval } from "../prices/priceUtils/priceIntervalUtils.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { getFreeTrialAfterFingerprint } from "../free-trials/freeTrialUtils.js"; export const sortProductItems = (items: ProductItem[], features: Feature[]) => { items.sort((a, b) => { @@ -287,7 +291,8 @@ export const getAttachScenario = ({ return isUpgrade ? AttachScenario.Upgrade : AttachScenario.Downgrade; }; -export const toPricecnProduct = ({ +export const toPricecnProduct = async ({ + db, org, product, fullProduct, @@ -295,7 +300,9 @@ export const toPricecnProduct = ({ features, curMainProduct, curScheduledProduct, + fullCus, }: { + db: DrizzleCli; org: Organization; product: ProductV2; fullProduct: FullProduct; @@ -303,6 +310,7 @@ export const toPricecnProduct = ({ features: Feature[]; curMainProduct?: FullCusProduct | null; curScheduledProduct?: FullCusProduct | null; + fullCus?: FullCustomer; }) => { let items = structuredClone(product.items); @@ -389,6 +397,21 @@ export const toPricecnProduct = ({ intervalGroup = getFirstInterval({ prices: fullProduct.prices }); } + let trialAvailable = false; + if (product.free_trial && fullCus) { + let trial = await getFreeTrialAfterFingerprint({ + db, + freeTrial: product.free_trial, + fingerprint: fullCus.fingerprint, + internalCustomerId: fullCus.internal_id, + multipleAllowed: org.config.multiple_trials, + productId: product.id, + }); + + if (scenario == AttachScenario.Downgrade) trial = null; + trialAvailable = notNullish(trial) ? true : false; + } + return { id: product.id, name, @@ -406,10 +429,10 @@ export const toPricecnProduct = ({ scenario, button_text: buttonText, free_trial: freeTrial - ? { - length: freeTrial, - interval: freeTrial.duration, - } + ? FreeTrialResponseSchema.parse({ + ...freeTrial, + trial_available: trialAvailable, + }) : null, interval_group: intervalGroup, diff --git a/server/src/internal/products/productUtils/detectProductVariant.ts b/server/src/internal/products/productUtils/detectProductVariant.ts index 928df239c..3dc95d40a 100644 --- a/server/src/internal/products/productUtils/detectProductVariant.ts +++ b/server/src/internal/products/productUtils/detectProductVariant.ts @@ -59,7 +59,10 @@ export const detectBaseVariant = async ({ nullish(p.base_variant_id) && !p.is_add_on && p.prices.length > 0 && - p.prices.every((price) => price.config.interval == BillingInterval.Month), + p.prices.every( + (price) => price.config.interval == BillingInterval.Month, + ) && + p.group == curProduct.group, ); if (filteredExistingProducts.length == 0) return null; diff --git a/server/src/internal/products/productV2Utils.ts b/server/src/internal/products/productV2Utils.ts index 1fa51b1ea..47afc2362 100644 --- a/server/src/internal/products/productV2Utils.ts +++ b/server/src/internal/products/productV2Utils.ts @@ -2,6 +2,8 @@ import { EntitlementWithFeature, Feature, FeatureType, + FreeTrial, + FreeTrialResponseSchema, FullProduct, Price, ProductItem, @@ -109,9 +111,11 @@ export const mapToProductV2 = ({ export const getProductResponse = ({ product, features, + trialAvailable, }: { product: FullProduct; features: Feature[]; + trialAvailable?: boolean; }) => { let items = mapToProductItems({ prices: product.prices, @@ -134,5 +138,13 @@ export const getProductResponse = ({ name: product.name || null, group: product.group || null, items: items, + free_trial: product.free_trial + ? FreeTrialResponseSchema.parse({ + duration: product.free_trial?.duration, + length: product.free_trial?.length, + unique_fingerprint: product.free_trial?.unique_fingerprint, + trial_available: trialAvailable, + }) + : null, }); }; diff --git a/shared/models/productModels/freeTrialModels/freeTrialModels.ts b/shared/models/productModels/freeTrialModels/freeTrialModels.ts index 983d9c8fb..0d045a34d 100644 --- a/shared/models/productModels/freeTrialModels/freeTrialModels.ts +++ b/shared/models/productModels/freeTrialModels/freeTrialModels.ts @@ -26,6 +26,7 @@ export const FreeTrialResponseSchema = z.object({ duration: z.nativeEnum(FreeTrialDuration), length: z.number(), unique_fingerprint: z.boolean(), + trial_available: z.boolean().nullish().default(true), }); export type FreeTrial = z.infer;