From b3dfc1babffdf76200ea621e2d3ec6b6da577e1c Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 18 Jul 2025 10:36:07 +0100 Subject: [PATCH] feat: checkout endpoint --- pnpm-lock.yaml | 3 + server/src/check.ts | 18 ++++- server/src/cron.ts | 44 +++++----- server/src/external/stripe/stripeWebhooks.ts | 2 +- .../attach/attachUtils/mapOptionsList.ts | 7 +- .../attach/checkout/getHasProrations.ts | 36 +++++++++ .../attach/checkout/handleCheckout.ts | 15 +++- .../attach/checkout/previewToCheckoutRes.ts | 42 ++++++++-- .../getDowngradeProductPreview.ts | 10 ++- .../getNewProductPreview.ts | 7 +- .../getUpgradeProductPreview.ts | 11 +-- .../cusEnts/CusEntitlementService.ts | 81 ++++++++++++------- .../previewItemUtils/getItemsForNewProduct.ts | 3 +- .../productResponseUtils/getAttachScenario.ts | 10 ++- .../getProductResponse.ts | 31 ++++--- server/tests/utils/stripeUtils.ts | 35 +++----- shared/models/attachModels/checkoutModels.ts | 10 ++- .../productV2Models/productResponseModels.ts | 2 + vite/package.json | 1 + vite/src/components/autumn/pricing-table.tsx | 14 ++-- 20 files changed, 262 insertions(+), 120 deletions(-) create mode 100644 server/src/internal/customers/attach/checkout/getHasProrations.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8106a05f..c7753f7ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,6 +410,9 @@ importers: ag-charts-community: specifier: ^12.0.2 version: 12.0.2 + ag-grid-community: + specifier: ^34.0.2 + version: 34.0.2 ag-grid-react: specifier: ^34.0.2 version: 34.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) diff --git a/server/src/check.ts b/server/src/check.ts index 2edebfd79..0be7ad873 100644 --- a/server/src/check.ts +++ b/server/src/check.ts @@ -12,7 +12,10 @@ import { } from "@autumn/shared"; import Stripe from "stripe"; import assert from "assert"; -import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { + cusProductsToCusEnts, + cusProductToPrices, +} from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { findStripeItemForPrice, isLicenseItem, @@ -30,6 +33,7 @@ import { CusService } from "@/internal/customers/CusService.js"; import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { createSupabaseClient } from "@/external/supabaseUtils.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; +import { getRelatedCusPrice } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js"; const { db, client } = initDrizzle({ maxConnections: 5 }); let orgSlugs = process.env.ORG_SLUGS!.split(","); @@ -183,12 +187,20 @@ const checkCustomerCorrect = async ({ } } - // console.log("prices:", prices); - // console.log("subItems:", subItems); assert( prices.length - missingUsageCount === subItems.length, `(${cusProduct.product.name}) number of sub items equivalent to number of prices` ); + + for (const cusEnt of cusProduct.customer_entitlements) { + let cusPrice = getRelatedCusPrice(cusEnt, cusProduct.customer_prices); + + if (cusEnt.usage_allowed && !cusPrice) { + assert.fail( + `Feature ${cusEnt.feature_id} has usage allowed but no related cus price` + ); + } + } } // Other checks to perform diff --git a/server/src/cron.ts b/server/src/cron.ts index f5730e40a..fb15e40e6 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -72,11 +72,11 @@ const checkSubAnchor = async ({ console.log("Checking billing cycle anchor"); console.log( "Next reset at ", - format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss"), + format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss") ); console.log( "Billing cycle anchor", - format(new UTCDate(billingCycleAnchor), "dd MMM yyyy HH:mm:ss"), + format(new UTCDate(billingCycleAnchor), "dd MMM yyyy HH:mm:ss") ); const billingCycleDay = getDate(new UTCDate(billingCycleAnchor)); @@ -116,7 +116,7 @@ const resetCustomerEntitlement = async ({ const entOptions = getEntOptions( cusEnt.customer_product.options, - cusEnt.entitlement, + cusEnt.entitlement ); const resetBalance = getResetBalance({ @@ -140,10 +140,10 @@ const resetCustomerEntitlement = async ({ console.log( `Reset ${cusEnt.id} | customer: ${chalk.yellow( - cusEnt.customer_id, + cusEnt.customer_id )} | feature: ${chalk.yellow( - cusEnt.feature_id, - )} | new balance: unlimited`, + cusEnt.feature_id + )} | new balance: unlimited` ); return; } @@ -159,16 +159,16 @@ const resetCustomerEntitlement = async ({ console.log( `Reset ${cusEnt.id} | customer: ${chalk.yellow( - cusEnt.customer_id, + cusEnt.customer_id )} | feature: ${chalk.yellow( - cusEnt.feature_id, - )} | reset to lifetime (next_reset_at: null)`, + cusEnt.feature_id + )} | reset to lifetime (next_reset_at: null)` ); return; } let nextResetAt = getNextResetAt( new UTCDate(cusEnt.next_reset_at!), - cusEnt.entitlement.interval as EntInterval, + cusEnt.entitlement.interval as EntInterval ); let resetBalanceUpdate = getResetBalancesUpdate({ @@ -199,18 +199,18 @@ const resetCustomerEntitlement = async ({ console.log( `Reset ${cusEnt.id} | customer: ${chalk.yellow( - cusEnt.customer_id, + cusEnt.customer_id )} | feature: ${chalk.yellow( - cusEnt.feature_id, + cusEnt.feature_id )} | new balance: ${chalk.green( - resetBalance, + resetBalance )} | new next_reset_at: ${chalk.green( - format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss"), - )}`, + format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss") + )}` ); } catch (error: any) { console.log( - `Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}`, + `Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}` ); } }; @@ -218,16 +218,16 @@ const resetCustomerEntitlement = async ({ export const cronTask = async () => { console.log( "\n----------------------------------\nRUNNING RESET CRON:", - format(new UTCDate(), "yyyy-MM-dd HH:mm:ss"), + format(new UTCDate(), "yyyy-MM-dd HH:mm:ss") ); const { db, client } = initDrizzle(); try { let cusEnts: FullCusEntWithProduct[] = - await CusEntService.getActiveResetPassed({ db }); + await CusEntService.getActiveResetPassed({ db, batchSize: 500 }); - const batchSize = 20; + const batchSize = 50; for (let i = 0; i < cusEnts.length; i += batchSize) { const batch = cusEnts.slice(i, i + batchSize); const batchResets = []; @@ -236,7 +236,7 @@ export const cronTask = async () => { resetCustomerEntitlement({ db, cusEnt: cusEnt as FullCusEntWithProduct, - }), + }) ); } @@ -245,7 +245,7 @@ export const cronTask = async () => { console.log( "FINISHED RESET CRON:", - format(new UTCDate(), "yyyy-MM-dd HH:mm:ss"), + format(new UTCDate(), "yyyy-MM-dd HH:mm:ss") ); console.log("----------------------------------\n"); } catch (error) { @@ -263,7 +263,7 @@ const job = new CronJob( }, null, // onComplete true, // start immediately - "UTC", // timezone (adjust as needed) + "UTC" // timezone (adjust as needed) ); // job.start(); diff --git a/server/src/external/stripe/stripeWebhooks.ts b/server/src/external/stripe/stripeWebhooks.ts index ba40a905f..33a10669e 100644 --- a/server/src/external/stripe/stripeWebhooks.ts +++ b/server/src/external/stripe/stripeWebhooks.ts @@ -68,8 +68,8 @@ stripeWebhookRouter.post( return; } - const webhookSecret = getStripeWebhookSecret(org, env); try { + const webhookSecret = getStripeWebhookSecret(org, env); event = stripe.webhooks.constructEvent(request.body, sig, webhookSecret); } catch (err: any) { response.status(400).send(`Webhook Error: ${err.message}`); diff --git a/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts b/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts index 253029918..9154ba072 100644 --- a/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts +++ b/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts @@ -68,7 +68,12 @@ export const mapOptionsList = ({ newOption.internal_feature_id === option.internal_feature_id ); - if (!inNewOptions) { + const prepaidPriceExists = findPrepaidPrice({ + prices, + internalFeatureId: option.internal_feature_id!, + }); + + if (!inNewOptions && prepaidPriceExists) { newOptionsList.push(option); } } diff --git a/server/src/internal/customers/attach/checkout/getHasProrations.ts b/server/src/internal/customers/attach/checkout/getHasProrations.ts new file mode 100644 index 000000000..2b99bd981 --- /dev/null +++ b/server/src/internal/customers/attach/checkout/getHasProrations.ts @@ -0,0 +1,36 @@ +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { AttachBranch } from "@autumn/shared"; +import { + attachParamsToCurCusProduct, + attachParamToCusProducts, +} from "../attachUtils/convertAttachParams.js"; +import { cusProductToPrices } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; +import { isFreeProduct } from "@/internal/products/productUtils.js"; + +export const getHasProrations = async ({ + req, + branch, + attachParams, +}: { + req: ExtendedRequest; + branch: AttachBranch; + attachParams: AttachParams; +}) => { + let hasProrations = false; + + let { curMainProduct } = attachParamToCusProducts({ attachParams }); + if (branch == AttachBranch.Upgrade) { + let curPrices = cusProductToPrices({ cusProduct: curMainProduct! }); + + if (!isFreeProduct(curPrices)) { + return true; + } + } + + if (branch == AttachBranch.UpdatePrepaidQuantity) { + return true; + } + + return false; +}; diff --git a/server/src/internal/customers/attach/checkout/handleCheckout.ts b/server/src/internal/customers/attach/checkout/handleCheckout.ts index 102975ad7..a8f6da458 100644 --- a/server/src/internal/customers/attach/checkout/handleCheckout.ts +++ b/server/src/internal/customers/attach/checkout/handleCheckout.ts @@ -1,12 +1,11 @@ import { + AttachBranch, AttachFunction, AttachScenario, CheckoutResponseSchema, FeatureOptions, - FreeTrialResponseSchema, - ProductItemResponseSchema, - ProductResponseSchema, } from "@autumn/shared"; + import { routeHandler } from "@/utils/routerUtils.js"; import { getAttachParams } from "../attachUtils/attachParams/getAttachParams.js"; import { AttachBody, AttachBodySchema } from "@autumn/shared"; @@ -16,7 +15,6 @@ import { getAttachBranch } from "../attachUtils/getAttachBranch.js"; import { getAttachConfig } from "../attachUtils/getAttachConfig.js"; import { getAttachFunction } from "../attachUtils/getAttachFunction.js"; import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js"; -import { z } from "zod"; import { checkStripeConnections } from "../attachRouter.js"; import { attachParamsToPreview } from "../handleAttachPreview/attachParamsToPreview.js"; import { previewToCheckoutRes } from "./previewToCheckoutRes.js"; @@ -26,6 +24,7 @@ import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js"; import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js"; import { getPriceOptions } from "@/internal/products/prices/priceUtils.js"; +import { getHasProrations } from "./getHasProrations.js"; const getAttachVars = async ({ req, @@ -167,12 +166,20 @@ export const handleCheckout = (req: any, res: any) => preview, }); + // Get has prorations + const hasProrations = await getHasProrations({ + req, + branch, + attachParams, + }); + res.status(200).json({ ...checkoutRes, options: attachParams.optionsList.map((o) => ({ quantity: o.quantity, feature_id: o.feature_id, })), + has_prorations: hasProrations, }); return; diff --git a/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts b/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts index af01b8c4d..322eb1a0a 100644 --- a/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts +++ b/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts @@ -2,12 +2,11 @@ import { AttachPreview, CheckoutLine, CheckoutResponseSchema, + UsageModel, } from "@autumn/shared"; import { AttachParams } from "../../cusProducts/AttachParams.js"; -import { getAttachScenario } from "@/internal/api/entitled/handlers/attachToCheckPreview/getAttachScenario.js"; import { - attachParamsToCurCusProduct, attachParamsToProduct, attachParamToCusProducts, } from "../attachUtils/convertAttachParams.js"; @@ -23,7 +22,9 @@ import { } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; import { toProductItem } from "@/internal/products/product-items/mapToItem.js"; import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; +import { formatUnixToDateTime, notNullish } from "@/utils/genUtils.js"; +import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js"; +import { Decimal } from "decimal.js"; export const previewToCheckoutRes = async ({ req, @@ -36,7 +37,6 @@ export const previewToCheckoutRes = async ({ }) => { const { logger, features, org } = req; const product = attachParamsToProduct({ attachParams }); - const scenario = await getAttachScenario({ preview, product }); const { curCusProduct } = attachParamToCusProducts({ attachParams }); let curPrices = curCusProduct @@ -83,6 +83,7 @@ export const previewToCheckoutRes = async ({ features, currency: org.default_currency, options: attachParams.optionsList, + fullCus: attachParams.customer, }); let curProduct = curCusProduct @@ -96,17 +97,46 @@ export const previewToCheckoutRes = async ({ const total = lines.reduce((acc, line) => acc + line.amount, 0); + let nextCycle = undefined; + + if (notNullish(preview.due_next_cycle)) { + nextCycle = { + starts_at: preview.due_next_cycle.due_at, + total: newProduct.items + .reduce((acc, item) => { + if (item.usage_model == UsageModel.PayPerUse) { + return acc; + } + + // if (item.interval !== newProduct.properties?.interval_group) { + // return acc; + // } + + if (isPriceItem(item)) { + return acc.plus(item.price || 0); + } + + let prepaidQuantity = + attachParams.optionsList.find( + (o) => o.feature_id == item.feature_id + )?.quantity || 0; + + return acc.plus(prepaidQuantity * (item.price || 0)); + }, new Decimal(0)) + .toNumber(), + }; + } + return CheckoutResponseSchema.parse({ customer_id: attachParams.customer.id, - scenario, lines, product: newProduct, current_product: curProduct, total, currency: org.default_currency || "usd", - // next_cycle: nextCycle, next_cycle_at: notNullish(preview.due_next_cycle) ? preview.due_next_cycle.due_at : null, + next_cycle: nextCycle, }); }; diff --git a/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts index ade4b59c9..2977d4cac 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts @@ -20,10 +20,10 @@ export const getDowngradeProductPreview = async ({ }) => { const newProduct = attachParamsToProduct({ attachParams }); - const { curMainProduct } = attachParamToCusProducts({ attachParams }); + const { curCusProduct } = attachParamToCusProducts({ attachParams }); const stripeSubs = await getStripeSubs({ stripeCli: attachParams.stripeCli, - subIds: curMainProduct?.subscription_ids || [], + subIds: curCusProduct?.subscription_ids || [], }); const anchorToUnix = stripeSubs[0].current_period_end * 1000; @@ -47,11 +47,15 @@ export const getDowngradeProductPreview = async ({ // anchorToUnix, }); + let nextCycleAt = curCusProduct?.trial_ends_at + ? curCusProduct.trial_ends_at + : anchorToUnix; + return { currency: attachParams.org.default_currency, due_next_cycle: { line_items: items, - due_at: anchorToUnix, + due_at: nextCycleAt, }, options, diff --git a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts index 95d5d9bec..510b41b6b 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts @@ -13,7 +13,12 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free import { getLastInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; import { getMergeCusProduct } from "../attachFunctions/addProductFlow/getMergeCusProduct.js"; -import { formatUnixToDate, notNullish, nullish } from "@/utils/genUtils.js"; +import { + formatUnixToDate, + formatUnixToDateTime, + notNullish, + nullish, +} from "@/utils/genUtils.js"; export const getNewProductPreview = async ({ branch, diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts index bf71c9536..1d61970c2 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts @@ -30,7 +30,7 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free import { Decimal } from "decimal.js"; import { intervalsAreSame } from "../attachUtils/getAttachConfig.js"; import { isFreeProduct } from "@/internal/products/productUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; +import { formatUnixToDateTime, notNullish } from "@/utils/genUtils.js"; const getNextCycleAt = ({ prices, @@ -60,17 +60,18 @@ const getNextCycleAt = ({ } if (willCycleReset) { - const minInterval = getLastInterval({ prices }); + const firstInterval = getFirstInterval({ prices }); return { - next_cycle_at: addBillingIntervalUnix(now, minInterval), + next_cycle_at: addBillingIntervalUnix(now, firstInterval), }; } - const minInterval = getLastInterval({ prices }); + const firstInterval = getFirstInterval({ prices }); const nextCycleAt = getAlignedIntervalUnix({ alignWithUnix: stripeSubs[0].current_period_end * 1000, - interval: minInterval, + interval: firstInterval, alwaysReturn: true, + now, }); return { diff --git a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts index fab6d251d..8455b5968 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts @@ -52,41 +52,62 @@ export class CusEntService { static async getActiveResetPassed({ db, customDateUnix, + batchSize = 1000, }: { db: DrizzleCli; customDateUnix?: number; + batchSize?: number; }) { - const data = await db - .select() - .from(customerEntitlements) - .innerJoin( - customerProducts, - eq(customerEntitlements.customer_product_id, customerProducts.id), - ) - .innerJoin( - entitlements, - eq(customerEntitlements.entitlement_id, entitlements.id), - ) - .innerJoin( - features, - eq(entitlements.internal_feature_id, features.internal_id), - ) - .where( - and( - eq(customerProducts.status, CusProductStatus.Active), - lt(customerEntitlements.next_reset_at, customDateUnix ?? Date.now()), - ), - ); + const allResults: FullCusEntWithProduct[] = []; + let offset = 0; + let hasMore = true; - return data.map((item) => ({ - ...item.customer_entitlements, - entitlement: { - ...item.entitlements, - feature: item.features, - }, - customer_product: item.customer_products, - replaceables: [], - })) as FullCusEntWithProduct[]; + while (hasMore) { + const data = await db + .select() + .from(customerEntitlements) + .innerJoin( + customerProducts, + eq(customerEntitlements.customer_product_id, customerProducts.id) + ) + .innerJoin( + entitlements, + eq(customerEntitlements.entitlement_id, entitlements.id) + ) + .innerJoin( + features, + eq(entitlements.internal_feature_id, features.internal_id) + ) + .where( + and( + eq(customerProducts.status, CusProductStatus.Active), + lt(customerEntitlements.next_reset_at, customDateUnix ?? Date.now()) + ) + ) + .limit(batchSize) + .offset(offset); + + if (data.length === 0) { + hasMore = false; + } else { + const mappedData = data.map((item) => ({ + ...item.customer_entitlements, + entitlement: { + ...item.entitlements, + feature: item.features, + }, + customer_product: item.customer_products, + replaceables: [], + })) as FullCusEntWithProduct[]; + + allResults.push(...mappedData); + offset += batchSize; + hasMore = data.length === batchSize; + console.log(`Fetched ${allResults.length} entitlements to reset`); + } + } + + return allResults; } static async update({ diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index 656dd8b2f..4595a81d1 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -8,7 +8,6 @@ import { FreeTrial, PreviewLineItem, BillingType, - AttachConfig, getFeatureInvoiceDescription, UsagePriceConfig, UsageModel, @@ -232,7 +231,7 @@ export const getItemsForNewProduct = async ({ billingUnits: (price.config as UsagePriceConfig).billing_units, prodName: newProduct.name, isPrepaid: true, - fromUnix: finalProration?.start, + fromUnix: now, }), amount, usage_model: UsageModel.Prepaid, diff --git a/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts b/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts index aad0893a3..6837d8d9a 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts @@ -1,6 +1,10 @@ import { cusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { AttachScenario, FullCustomer, FullProduct } from "@autumn/shared"; -import { isFreeProduct, isProductUpgrade } from "../../productUtils.js"; +import { + isFreeProduct, + isOneOff, + isProductUpgrade, +} from "../../productUtils.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; export const getAttachScenario = ({ @@ -19,6 +23,10 @@ export const getAttachScenario = ({ if (!curMainProduct || fullProduct.is_add_on) return AttachScenario.New; + if (isOneOff(fullProduct.prices)) { + return AttachScenario.New; + } + // 1. If current product is the same as the product, return active if (curMainProduct?.product.id == fullProduct.id) { if (curMainProduct.canceled_at != null) { diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts index a0424f8e4..672d716be 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts @@ -6,18 +6,17 @@ import { FreeTrialResponseSchema, FullCustomer, ProductItem, - Organization, AttachScenario, ProductPropertiesSchema, BillingInterval, FeatureOptions, UsageModel, + FreeTrial, + FreeTrialResponse, + Price, } from "@autumn/shared"; import { sortProductItems } from "../../pricecn/pricecnUtils.js"; -import { - getItemType, - isPriceItem, -} from "../../product-items/productItemUtils/getItemType.js"; +import { getItemType } from "../../product-items/productItemUtils/getItemType.js"; import { mapToProductItems } from "../../productV2Utils.js"; import { getProductItemDisplay } from "./getProductItemDisplay.js"; import { getAttachScenario } from "./getAttachScenario.js"; @@ -28,6 +27,7 @@ import { isFreeProduct, isOneOff } from "../../productUtils.js"; import { getFirstInterval } from "../../prices/priceUtils/priceIntervalUtils.js"; import { itemToPriceOrTiers } from "../../product-items/productItemUtils.js"; import { toAPIFeature } from "@/internal/features/utils/mapFeatureUtils.js"; +import { isPrepaidPrice } from "../../prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; export const getProductItemResponse = ({ item, @@ -123,16 +123,29 @@ export const getFreeTrialResponse = async ({ return null; }; -export const getProductProperties = ({ product }: { product: FullProduct }) => { +export const getProductProperties = ({ + product, + freeTrial, +}: { + product: FullProduct; + freeTrial?: FreeTrialResponse | null; +}) => { let firstInterval: any = getFirstInterval({ prices: product.prices }); if (firstInterval == BillingInterval.OneOff) { firstInterval = null; } + let hasFreeTrial = + notNullish(freeTrial) && freeTrial?.trial_available !== false; + return ProductPropertiesSchema.parse({ is_free: isFreeProduct(product.prices) || false, is_one_off: isOneOff(product.prices) || false, interval_group: firstInterval, + has_trial: hasFreeTrial, + has_prepaid: product.prices.some((p: Price) => + isPrepaidPrice({ price: p }) + ), }); }; @@ -180,12 +193,12 @@ export const getProductResponse = async ({ fullProduct: product, }); - let freeTrial = await getFreeTrialResponse({ + let freeTrial = (await getFreeTrialResponse({ db: db as DrizzleCli, product, fullCus, attachScenario, - }); + })) as FreeTrialResponse; return ProductResponseSchema.parse({ ...product, @@ -194,6 +207,6 @@ export const getProductResponse = async ({ items: items, free_trial: freeTrial || null, scenario: attachScenario, - properties: getProductProperties({ product }), + properties: getProductProperties({ product, freeTrial }), }); }; diff --git a/server/tests/utils/stripeUtils.ts b/server/tests/utils/stripeUtils.ts index bac3bd2b9..8aafb835f 100644 --- a/server/tests/utils/stripeUtils.ts +++ b/server/tests/utils/stripeUtils.ts @@ -1,14 +1,7 @@ import puppeteer from "puppeteer"; import { timeout } from "./genUtils.js"; import { Stripe } from "stripe"; -import { - AppEnv, - BillingInterval, - BillingType, - Customer, - FullProduct, - Organization, -} from "@autumn/shared"; +import { BillingInterval, Customer, FullProduct } from "@autumn/shared"; import { addDays, addHours, @@ -17,8 +10,6 @@ import { addWeeks, format, } from "date-fns"; -import { SupabaseClient } from "@supabase/supabase-js"; -import { getBillingType } from "@/internal/products/prices/priceUtils.js"; const STRIPE_TEST_CLOCK_TIMING = 20000; // 30s // const STRIPE_TEST_CLOCK_TIMING = 40000; // 30s @@ -26,7 +17,7 @@ const STRIPE_TEST_CLOCK_TIMING = 20000; // 30s export const completeCheckoutForm = async ( url: string, overrideQuantity?: number, - promoCode?: string, + promoCode?: string ) => { const browser = await puppeteer.launch({ headless: false, @@ -106,7 +97,7 @@ export const deleteAllStripeProducts = async ({ active: false, }); } - }), + }) ); console.log("Deleted", i, "of", stripeProds.data.length); } @@ -124,9 +115,7 @@ export const deleteAllStripeTestClocks = async ({ for (let i = 0; i < stripeTestClocks.data.length; i += batchSize) { const batch = stripeTestClocks.data.slice(i, i + batchSize); await Promise.all( - batch.map(async (clock) => - stripeCli.testHelpers.testClocks.del(clock.id), - ), + batch.map(async (clock) => stripeCli.testHelpers.testClocks.del(clock.id)) ); } }; @@ -149,7 +138,7 @@ export const deleteStripeProduct = async ({ const config = price.config as any; if (config.stripe_price_id) { const stripePrice = await stripeCli.prices.retrieve( - config.stripe_price_id, + config.stripe_price_id ); await stripeCli.prices.update(config.stripe_price_id, { @@ -252,7 +241,7 @@ export const advanceTestClock = async ({ }); await timeout( - waitForSeconds ? waitForSeconds * 1000 : STRIPE_TEST_CLOCK_TIMING, + waitForSeconds ? waitForSeconds * 1000 : STRIPE_TEST_CLOCK_TIMING ); return advanceTo; @@ -297,7 +286,7 @@ export const advanceClockForInvoice = async ({ console.log( " - advanceClockForInvoice (1): ", - format(advanceTo, "dd MMM yyyy HH:mm:ss"), + format(advanceTo, "dd MMM yyyy HH:mm:ss") ); if (waitForMeterUpdate) { @@ -318,7 +307,7 @@ export const advanceClockForInvoice = async ({ console.log( " - advanceClockForInvoice (2): ", - format(advanceTo2, "dd MMM yyyy HH:mm:ss"), + format(advanceTo2, "dd MMM yyyy HH:mm:ss") ); await timeout(STRIPE_TEST_CLOCK_TIMING); @@ -337,10 +326,10 @@ export const advanceMonths = async ({ let advanceTo = new Date(); for (let i = 0; i < numberOfMonths; i += 1) { // let numMonths = Math.min(numberOfMonths - i, 2); - (advanceTo = addMonths(advanceTo, 1)), 10; + ((advanceTo = addMonths(advanceTo, 1)), 10); console.log( " - Advancing to: ", - format(advanceTo, "dd MMM yyyy HH:mm:ss"), + format(advanceTo, "dd MMM yyyy HH:mm:ss") ); try { @@ -379,7 +368,7 @@ export const checkBillingMeterEventSummary = async ({ start_time: Math.round(startTime.getTime() / 1000), end_time: Math.round(endTime.getTime() / 1000), customer: stripeCustomerId, - }, + } ); if (event.data.length === 0) { @@ -402,7 +391,7 @@ export const getDiscount = async ({ stripeId || customer!.processor!.id, { expand: ["discount.coupon"], - }, + } ); return stripeCustomer.discount; diff --git a/shared/models/attachModels/checkoutModels.ts b/shared/models/attachModels/checkoutModels.ts index 5ac939db0..79ed7cd1c 100644 --- a/shared/models/attachModels/checkoutModels.ts +++ b/shared/models/attachModels/checkoutModels.ts @@ -13,14 +13,20 @@ export const CheckoutLineSchema = z.object({ export const CheckoutResponseSchema = z.object({ url: z.string().nullish(), customer_id: z.string(), - scenario: z.nativeEnum(AttachScenario), lines: z.array(CheckoutLineSchema), product: ProductResponseSchema.nullish(), current_product: ProductResponseSchema.nullish(), options: z.array(FeatureOptionsSchema).nullish(), total: z.number().nullish(), currency: z.string().nullish(), - next_cycle_at: z.number().nullish(), + has_prorations: z.boolean().nullish(), + // next_cycle_at: z.number().nullish(), + next_cycle: z + .object({ + starts_at: z.number().nullish(), + total: z.number().nullish(), + }) + .nullish(), }); export type CheckoutLine = z.infer; diff --git a/shared/models/productV2Models/productResponseModels.ts b/shared/models/productV2Models/productResponseModels.ts index 0690d4be8..aa45a9625 100644 --- a/shared/models/productV2Models/productResponseModels.ts +++ b/shared/models/productV2Models/productResponseModels.ts @@ -8,6 +8,8 @@ export const ProductPropertiesSchema = z.object({ is_free: z.boolean(), is_one_off: z.boolean(), interval_group: z.string().nullish(), + has_trial: z.boolean().nullish(), + has_prepaid: z.boolean().nullish(), }); export const ProductResponseSchema = z.object({ diff --git a/vite/package.json b/vite/package.json index 51aca56e6..d3a93f053 100644 --- a/vite/package.json +++ b/vite/package.json @@ -36,6 +36,7 @@ "@types/recharts": "^2.0.1", "@wooorm/starry-night": "^3.6.0", "ag-charts-community": "^12.0.2", + "ag-grid-community": "^34.0.2", "ag-grid-react": "^34.0.2", "autumn-js": "^0.0.93", "axios": "^1.8.3", diff --git a/vite/src/components/autumn/pricing-table.tsx b/vite/src/components/autumn/pricing-table.tsx index ee3ff1f3d..7517353fd 100644 --- a/vite/src/components/autumn/pricing-table.tsx +++ b/vite/src/components/autumn/pricing-table.tsx @@ -32,8 +32,8 @@ export default function PricingTable({ const intervals = Array.from( new Set( - products?.map((p) => p.properties?.interval_group).filter((i) => !!i), - ), + products?.map((p) => p.properties?.interval_group).filter((i) => !!i) + ) ); const multiInterval = intervals.length > 1; @@ -151,7 +151,7 @@ export const PricingTableContainer = ({ {multiInterval && (
p.display?.recommend_text) && "mb-8", + products.some((p) => p.display?.recommend_text) && "mb-8" )} > {children} @@ -214,7 +214,7 @@ export const PricingCard = ({ " w-full h-full py-6 text-foreground border rounded-lg shadow-sm max-w-xl", isRecommended && "lg:-translate-y-6 lg:shadow-lg dark:shadow-zinc-800/80 lg:h-[calc(100%+48px)] bg-secondary/40", - className, + className )} > {productDisplay?.recommend_text && ( @@ -223,7 +223,7 @@ export const PricingCard = ({
@@ -339,7 +339,7 @@ export const PricingCardButton = React.forwardRef<