diff --git a/server/src/internal/api/entitled/entitledRouter.ts b/server/src/internal/api/entitled/entitledRouter.ts index 06a9b4302..ae8c61fb5 100644 --- a/server/src/internal/api/entitled/entitledRouter.ts +++ b/server/src/internal/api/entitled/entitledRouter.ts @@ -309,6 +309,8 @@ entitledRouter.post("", async (req: any, res: any) => { entity_id, } = req.body; + const { logtail: logger } = req; + if (!customer_id) { throw new RecaseError({ message: "Customer ID is required", @@ -408,16 +410,21 @@ entitledRouter.post("", async (req: any, res: any) => { // 3. If with preview, get preview let preview = undefined; if (req.body.with_preview) { - let featureToUse = creditSystems.length > 0 ? creditSystems[0] : feature; - preview = await getCheckPreview({ - allowed, - balance: balances.find( - (balance: any) => balance.feature_id === featureToUse.id - )?.balance, - feature: featureToUse, - sb, - cusProducts, - }); + try { + let featureToUse = + creditSystems.length > 0 ? creditSystems[0] : feature; + preview = await getCheckPreview({ + allowed, + balance: balances.find( + (balance: any) => balance.feature_id === featureToUse.id + )?.balance, + feature: featureToUse, + sb, + cusProducts, + }); + } catch (error) { + logger.error("Failed to get check preview", error); + } } if (org.api_version == APIVersion.v1_1) { diff --git a/server/src/internal/customers/change-product/handleUpgrade.ts b/server/src/internal/customers/change-product/handleUpgrade.ts index a92153759..7bb416d7f 100644 --- a/server/src/internal/customers/change-product/handleUpgrade.ts +++ b/server/src/internal/customers/change-product/handleUpgrade.ts @@ -20,7 +20,6 @@ import { APIVersion, } from "@autumn/shared"; -import { SupabaseClient } from "@supabase/supabase-js"; import { StatusCodes } from "http-status-codes"; import Stripe from "stripe"; import { createFullCusProduct } from "../add-product/createFullCusProduct.js"; diff --git a/server/src/internal/customers/previews/checkProductUtils.ts b/server/src/internal/customers/previews/checkProductUtils.ts index 79107a7df..5cb08158f 100644 --- a/server/src/internal/customers/previews/checkProductUtils.ts +++ b/server/src/internal/customers/previews/checkProductUtils.ts @@ -6,14 +6,53 @@ import { isFeatureItem, isPriceItem, } from "@/internal/products/product-items/getItemType.js"; -import { itemToPriceOrTiers } from "@/internal/products/product-items/productItemUtils.js"; -import { - Feature, - FullProduct, - Organization, - ProductItem, - ProductV2, -} from "@autumn/shared"; + +import { Feature, Organization, ProductItem, ProductV2 } from "@autumn/shared"; +import { formatCurrency, formatTiers } from "./previewUtils.js"; +import { isFeaturePriceItem } from "@/internal/products/product-items/productItemUtils.js"; + +export const getProductChargeText = ({ + product, + org, + features, +}: { + product: ProductV2; + org: Organization; + features: Feature[]; +}) => { + let basePrices = product.items.filter((i) => isPriceItem(i)); + let total = basePrices.reduce((acc, curr) => acc + curr.price!, 0); + + let itemStrs = []; + if (total > 0) { + itemStrs.push( + formatCurrency({ + amount: total, + defaultCurrency: org.default_currency, + }) + ); + } + + let prepaidPrices = product.items.filter( + (i) => isFeaturePriceItem(i) && i.usage_model == "prepaid" + ); + + let prepaidStrings = prepaidPrices.map((i) => { + let feature = features.find((f) => f.id === i.feature_id); + let priceStr = formatTiers({ + tiers: i.tiers!, + org, + }); + + let featureStr = + i.billing_units && i.billing_units > 1 + ? `${i.billing_units} ${feature?.name}` + : feature?.name; + + return `${priceStr} / ${featureStr}`; + }); + return [...itemStrs, ...prepaidStrings]; +}; export const getItemDescription = ({ item, @@ -28,7 +67,6 @@ export const getItemDescription = ({ }) => { let prices = product.items.filter((i) => !isFeatureItem(i)); - // let { price, tiers } = itemToPriceOrTiers(item); let priceStr = getPriceText({ item, org, diff --git a/server/src/internal/customers/previews/getNewProductPreview.ts b/server/src/internal/customers/previews/getNewProductPreview.ts index a08dcb634..6321c215c 100644 --- a/server/src/internal/customers/previews/getNewProductPreview.ts +++ b/server/src/internal/customers/previews/getNewProductPreview.ts @@ -1,33 +1,20 @@ import { createStripeCli } from "@/external/stripe/utils.js"; import { - BillingType, - CheckProdItemSchema, Feature, FullCusProduct, FullProduct, Organization, + UsageModel, } from "@autumn/shared"; import { AppEnv } from "@autumn/shared"; import { Customer } from "@autumn/shared"; -import { - handleBillNowPrices, - handleOneOffPrices, -} from "../add-product/handleAddProduct.js"; -import { formatCurrency, getItemsHtml, itemsToHtml } from "./previewUtils.js"; -import { - mapToProductItems, - mapToProductV2, -} from "@/internal/products/productV2Utils.js"; -import { - getBillingType, - getPriceEntitlement, -} from "@/internal/prices/priceUtils.js"; -import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js"; + +import { mapToProductV2 } from "@/internal/products/productV2Utils.js"; import { isOneOff } from "@/internal/products/productUtils.js"; -import { itemToPriceOrTiers } from "@/internal/products/product-items/productItemUtils.js"; -import { getItemDescription } from "./checkProductUtils.js"; +import { isFeaturePriceItem } from "@/internal/products/product-items/productItemUtils.js"; +import { getProductChargeText } from "./checkProductUtils.js"; import { isFeatureItem } from "@/internal/products/product-items/getItemType.js"; import { sortProductItems } from "@/internal/products/pricecn/pricecnUtils.js"; @@ -58,95 +45,39 @@ export const getNewProductPreview = async ({ }); let sortedItems = sortProductItems(productV2.items, features); - let items = sortedItems - .filter((i) => !isFeatureItem(i)) - .map((item) => { + let items = sortedItems.filter((i) => !isFeatureItem(i)); + let itemStrs = getProductChargeText({ + product: productV2, + org, + features, + }); + + let message = `By clicking confirm, you will subscribe to ${product.name} and the following amount will be charged:\n`; + for (let item of itemStrs) { + message += `\n${item}`; + } + + let title = ""; + if (isOneOff(product.prices)) { + title = `Purchase ${product.name}`; + } else { + title = `Subscribe to ${product.name}`; + } + + let options = items + .filter((i) => isFeaturePriceItem(i) && i.usage_model == UsageModel.Prepaid) + .map((i) => { return { - description: getItemDescription({ - item, - features, - product: productV2, - org, - }), + feature_id: i.feature_id, + feature_name: features.find((f) => f.id == i.feature_id)?.name, + billing_units: i.billing_units, }; }); - console.log("items", items); - throw new Error("Not implemented"); - - // if (isOneOff(product.prices)) { - // let invoiceItems = await handleOneOffPrices({ - // sb: null, - // attachParams, - // req: { - // logtail: console, - // }, - // res: null, - // fromRequest: false, - // shouldPreview: true, - // }) || []; - - // for (let item of invoiceItems) { - // delete item.description; - // } - // throw new Error("Not implemented"); - // // let items = res?.lines?.data.map((line: any) => { - // // return { - // // name: line.description, - // // amount: line.amount / 100, - // // currency: line.currency, - // // }; - // // }); - // } else { - // res = (await handleBillNowPrices({ - // sb: null, - // attachParams, - // req: { - // logtail: console, - // }, - // res: null, - // fromRequest: false, - // shouldPreview: true, - // })) as any; - - // let items = res?.lines?.data.map((line: any) => { - // let price = product.prices.find( - // (p: any) => p.config.stripe_price_id === line.price.id - // ); - // let tiers = (price?.config as any)?.usage_tiers; - // let entitlement; - - // if (price) { - // entitlement = getPriceEntitlement(price, product.entitlements); - // } - - // return { - // name: entitlement - // ? `${product.name} (${entitlement?.feature.name})` - // : `${product.name} (Base)`, - // // description: line.description, - // amount: line.amount / 100, - // currency: line.currency, - // tiers: tiers, - // }; - // }); - // } - - // let html = `
By clicking confirm, you will subscribe to ${product.name} and the following amount will be charged immediately:
`; - // html += getItemsHtml({ items: items, org: org }); - - // ${formatCurrency({ - // amount: totalAmount, - // defaultCurrency: items?.[0]?.currency, - // })} - - let message = `By clicking confirm, you will subscribe to ${product.name} and the following amount will be charged immediately:`; - - // console.log("items", items); return { - title: `Upgrade to ${product.name}`, + title, message, - line_items: items, due_when: "immediately", + options, }; }; diff --git a/server/src/internal/customers/previews/getUpgradePreview.ts b/server/src/internal/customers/previews/getUpgradePreview.ts index 9f4e82e8c..dd275084a 100644 --- a/server/src/internal/customers/previews/getUpgradePreview.ts +++ b/server/src/internal/customers/previews/getUpgradePreview.ts @@ -78,25 +78,28 @@ const formatMessage = ({ ? "will be charged to your card immediately" : "will be added to your next bill"; - let html = `By clicking confirm, you will upgrade your plan to ${product.name} and the following amount ${addString}.
`; - - html += `Total: ${formatCurrency({ - amount: totalAmount, - defaultCurrency: org.default_currency, - })}
`; - let message = `By clicking confirm, you will upgrade your plan to ${ product.name } and ${formatCurrency({ amount: totalAmount, defaultCurrency: org.default_currency, - })} ${addString}.`; + })} ${addString}:\n`; - return { html, message }; + for (let item of baseLineItems) { + message += `\n${item.description}: ${formatCurrency({ + amount: item.amount, + defaultCurrency: org.default_currency, + })}`; + } + + for (let item of usageLineItems) { + message += `\n${item.description}: ${formatCurrency({ + amount: item.amount, + defaultCurrency: org.default_currency, + })}`; + } + + return { message }; }; export const getUpgradePreview = async ({ @@ -180,7 +183,6 @@ export const getUpgradePreview = async ({ return { title: `Upgrade to ${product.name}`, message: formattedMessage.message, - html: formattedMessage.html, amount_due: Number(totalAmount.toFixed(2)), due_when: org.config.bill_upgrade_immediately ? "immediately" diff --git a/server/src/internal/customers/previews/previewUtils.ts b/server/src/internal/customers/previews/previewUtils.ts index 0a8429f3b..90a99219a 100644 --- a/server/src/internal/customers/previews/previewUtils.ts +++ b/server/src/internal/customers/previews/previewUtils.ts @@ -1,4 +1,4 @@ -import { Organization } from "@autumn/shared"; +import { Organization, PriceTier } from "@autumn/shared"; export const formatCurrency = ({ amount, @@ -11,7 +11,35 @@ export const formatCurrency = ({ style: "currency", currency: defaultCurrency || "usd", }); - return formatter.format(Math.abs(amount)); + return formatter.format(amount); +}; + +export const formatTiers = ({ + tiers, + + org, +}: { + tiers: PriceTier[]; + + org: Organization; +}) => { + if (tiers.length == 1) { + return formatCurrency({ + amount: tiers[0].amount, + defaultCurrency: org.default_currency, + }); + } + + let tiersStart = formatCurrency({ + amount: tiers[0].amount, + defaultCurrency: org.default_currency, + }); + let tiersEnd = formatCurrency({ + amount: tiers[tiers.length - 1].amount, + defaultCurrency: org.default_currency, + }); + + return `${tiersStart} - ${tiersEnd}`; }; export const getItemsHtml = ({ diff --git a/server/tests/basic/multi-feature/multi_feature3.ts b/server/tests/basic/multi-feature/multi_feature3.ts index 5c3a7e5e4..b2538df9e 100644 --- a/server/tests/basic/multi-feature/multi_feature3.ts +++ b/server/tests/basic/multi-feature/multi_feature3.ts @@ -3,7 +3,7 @@ import chalk from "chalk"; import { Autumn } from "@/external/autumn/autumnCli.js"; import { features } from "tests/global.js"; import { setupBefore } from "tests/before.js"; -import { initCustomer } from "tests/utils/init.js"; + import { AppEnv, BillingInterval, @@ -24,12 +24,9 @@ import { } from "@/internal/products/product-items/productItemUtils.js"; import { SupabaseClient } from "@supabase/supabase-js"; import { timeout } from "@/utils/genUtils.js"; -import { - advanceClockForInvoice, - advanceTestClock, -} from "tests/utils/stripeUtils.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js"; -import { addDays } from "date-fns"; +import { addDays, addMonths } from "date-fns"; // Scenario 1: prepaid + pay per use monthly -> prepaid + pay per use monthly let pro = { @@ -163,16 +160,13 @@ describe(`${chalk.yellowBright( }); it("cycle 1:should have correct usage after first cycle", async function () { - let advanceTo = addDays(new Date(), 30).getTime(); + let advanceTo = addMonths(new Date(), 1).getTime(); await advanceTestClock({ stripeCli: this.stripeCli, testClockId, advanceTo, }); - // let { invoices } = await autumn.customers.get(customerId); - // expect(invoices[0].total).to.equal(overageValue * (pro.items.payPerUse.amount ?? 0)); - let { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({ customerId, sb: this.sb, diff --git a/shared/models/productModels/productItemModels.ts b/shared/models/productModels/productItemModels.ts index 54abc8373..48c8d8f10 100644 --- a/shared/models/productModels/productItemModels.ts +++ b/shared/models/productModels/productItemModels.ts @@ -70,3 +70,4 @@ export const ProductItemSchema = z.object({ }); export type ProductItem = z.infer