diff --git a/server/src/external/vercel/handlers/handleListBillingPlans.ts b/server/src/external/vercel/handlers/handleListBillingPlans.ts index 4a53c2b51..07e59e3da 100644 --- a/server/src/external/vercel/handlers/handleListBillingPlans.ts +++ b/server/src/external/vercel/handlers/handleListBillingPlans.ts @@ -1,6 +1,7 @@ import { type AppEnv, type FullProduct, + formatAmount, getProductItemDisplay, isPriceItem, isUsagePrice, @@ -16,8 +17,6 @@ import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { CusService } from "@/internal/customers/CusService.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; - -import { formatAmount } from "@/utils/formatUtils.js"; import { sortProductsByPrice } from "../../../internal/products/productUtils/sortProductUtils.js"; import { isFreeProduct, diff --git a/server/src/internal/api/check/handlers/getProductCheckPreview.ts b/server/src/internal/api/check/handlers/getProductCheckPreview.ts index f37f3f51e..dcc094367 100644 --- a/server/src/internal/api/check/handlers/getProductCheckPreview.ts +++ b/server/src/internal/api/check/handlers/getProductCheckPreview.ts @@ -6,6 +6,7 @@ import { type Feature, type FullCustomer, type FullProduct, + formatAmount, type Organization, } from "@autumn/shared"; import { Decimal } from "decimal.js"; @@ -15,7 +16,6 @@ import { checkToAttachParams } from "@/internal/customers/attach/attachUtils/att import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; -import { formatAmount } from "@/utils/formatUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { getAttachScenario } from "./attachToCheckPreview/getAttachScenario.js"; diff --git a/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts b/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts index e7258e47b..fac77ff0c 100644 --- a/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts +++ b/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts @@ -46,6 +46,8 @@ export const enrichAttachActions = async ({ // 4. Get trial ends at (either from current subscription that we're merging with, or from new product)* // 5. Calculate line items for new product / upgrade* [let's do this] + // 1. Calculate line items for usages + // From billing cycle anchor, now, and interval, calculate latest cycle start: if (largestInterval && billingCycleAnchor) { const cycleStart = getCycleStart({ diff --git a/server/src/internal/invoices/invoiceFormatUtils.ts b/server/src/internal/invoices/invoiceFormatUtils.ts index f138f80d4..6bb1bfa8b 100644 --- a/server/src/internal/invoices/invoiceFormatUtils.ts +++ b/server/src/internal/invoices/invoiceFormatUtils.ts @@ -1,22 +1,20 @@ import { BillingInterval, BillingType, - billingToItemInterval, cusProductToEnts, type EntitlementWithFeature, type Feature, type FixedPriceConfig, type FullCusProduct, type FullProduct, + formatAmount, + formatInterval, getFeatureName, getFeatureNameWithCapital, - getIntervalString, type Organization, type Price, - type ProductItemInterval, type UsagePriceConfig, } from "@autumn/shared"; -import { formatAmount } from "@/utils/formatUtils.js"; import { getFeatureQuantity } from "../customers/cusProducts/cusProductUtils.js"; import { getBillingType, @@ -72,10 +70,8 @@ export const formatFixedPrice = ({ const config = price.config as FixedPriceConfig; const amount = formatAmount({ org, amount: config.amount }); - const intervalStr = getIntervalString({ - interval: billingToItemInterval({ - billingInterval: config.interval, - }) as ProductItemInterval, + const intervalStr = formatInterval({ + interval: config.interval, intervalCount: config.interval_count || 1, prefix: "", }); diff --git a/server/src/internal/invoices/previewItemUtils/constructPreviewItem.ts b/server/src/internal/invoices/previewItemUtils/constructPreviewItem.ts index e680e7390..120caea74 100644 --- a/server/src/internal/invoices/previewItemUtils/constructPreviewItem.ts +++ b/server/src/internal/invoices/previewItemUtils/constructPreviewItem.ts @@ -1,6 +1,5 @@ +import { formatAmount, type Organization, type Price } from "@autumn/shared"; import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js"; -import { formatAmount } from "@/utils/formatUtils.js"; -import { Organization, Price } from "@autumn/shared"; export const constructPreviewItem = ({ price, diff --git a/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts b/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts index 9082a86db..d356b38f5 100644 --- a/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts +++ b/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts @@ -2,6 +2,7 @@ import { BillingType, cusProductToEnts, cusProductToPrices, + formatAmount, getFeatureInvoiceDescription, InternalError, type PreviewLineItem, @@ -19,7 +20,6 @@ import { priceToUsageModel, } from "@/internal/products/prices/priceUtils/convertPrice.js"; import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js"; -import { formatAmount } from "@/utils/formatUtils.js"; import { formatUnixToDate } from "@/utils/genUtils.js"; import { calculateProrationAmount } from "../prorationUtils.js"; import { getProration } from "./getItemsForNewProduct.js"; diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts index 439dd13f3..e4aab3922 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts @@ -3,6 +3,7 @@ import { type AttachConfig, BillingType, cusProductToPrices, + formatAmount, InternalError, type PreviewLineItem, } from "@autumn/shared"; @@ -19,7 +20,6 @@ import { isContUsePrice, } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { getBillingType } from "@/internal/products/prices/priceUtils.js"; -import { formatAmount } from "@/utils/formatUtils.js"; import type { Logger } from "../../../external/logtail/logtailUtils"; export const getItemsForCurProduct = async ({ diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index 1c26b5d6b..8c6a200d7 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -5,6 +5,7 @@ import { type Feature, type FreeTrial, type FullProduct, + formatAmount, getFeatureInvoiceDescription, type IntervalConfig, isFixedPrice, @@ -31,7 +32,6 @@ import { priceToUsageModel, } from "@/internal/products/prices/priceUtils/convertPrice.js"; import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js"; -import { formatAmount } from "@/utils/formatUtils.js"; import { formatUnixToDate, notNullish } from "@/utils/genUtils.js"; import type { AttachParams } from "../../customers/cusProducts/AttachParams.js"; import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js"; diff --git a/server/src/internal/products/pricecn/pricecnUtils.ts b/server/src/internal/products/pricecn/pricecnUtils.ts index 059af32cd..fcebda279 100644 --- a/server/src/internal/products/pricecn/pricecnUtils.ts +++ b/server/src/internal/products/pricecn/pricecnUtils.ts @@ -6,6 +6,7 @@ import { type FullCusProduct, type FullCustomer, type FullProduct, + formatAmount, Infinite, numberWithCommas, type Organization, @@ -89,28 +90,20 @@ export const getPriceText = ({ item: ProductItem; org: Organization; }) => { - const formatAmount = (amount: number) => { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency: org.default_currency || "USD", - minimumFractionDigits: 0, - maximumFractionDigits: 10, - }).format(amount); - }; if (item.price) { - return formatAmount(item.price as number); + return formatAmount({ org, amount: item.price as number }); } const tiers = item.tiers; if (tiers) { if (tiers.length == 1) { - return formatAmount(tiers[0].amount); + return formatAmount({ org, amount: tiers[0].amount }); } const firstPrice = tiers[0].amount; const lastPrice = tiers[tiers.length - 1].amount; - return `${formatAmount(firstPrice)} - ${formatAmount(lastPrice)}`; + return `${formatAmount({ org, amount: firstPrice })} - ${formatAmount({ org, amount: lastPrice })}`; } }; diff --git a/server/src/internal/products/prices/priceUtils.ts b/server/src/internal/products/prices/priceUtils.ts index d034c937f..2635c236f 100644 --- a/server/src/internal/products/prices/priceUtils.ts +++ b/server/src/internal/products/prices/priceUtils.ts @@ -268,34 +268,6 @@ export const getUsageTier = (price: Price, quantity: number) => { return usageConfig.usage_tiers[0]; }; -export const getPriceAmount = ({ - price, - options, - relatedEnt, -}: { - price: Price; - options?: FeatureOptions; - relatedEnt?: EntitlementWithFeature; - quantity?: number; -}) => { - const billingType = getBillingType(price.config!); - if (billingType === BillingType.OneOff) { - const config = price.config as FixedPriceConfig; - return Number(config.amount.toFixed(2)); - } else if (billingType === BillingType.UsageInAdvance) { - const quantity = options?.quantity ?? 1; - const config = price.config as UsagePriceConfig; - - const overage = new Decimal(quantity) - .mul(config.billing_units || 1) - .toNumber(); - - return getPriceForOverage(price, overage); - } - - return 0; -}; - export const getPriceForOverage = (price: Price, overage?: number) => { const usageConfig = price.config as UsagePriceConfig; const billingType = getBillingType(usageConfig); diff --git a/server/src/internal/products/prices/priceUtils/arrearProratedUtils/getContUsageDowngradeItem.ts b/server/src/internal/products/prices/priceUtils/arrearProratedUtils/getContUsageDowngradeItem.ts index defb0d727..ffc825929 100644 --- a/server/src/internal/products/prices/priceUtils/arrearProratedUtils/getContUsageDowngradeItem.ts +++ b/server/src/internal/products/prices/priceUtils/arrearProratedUtils/getContUsageDowngradeItem.ts @@ -52,36 +52,10 @@ export const getContUsageDowngradeItem = ({ newOverage, }); - // let description = getFeatureInvoiceDescription({ - // feature, - // usage: newRoundedUsage, - // billingUnits: (price.config as UsagePriceConfig).billing_units, - // prodName: product.name, - // }); - return { newReplaceables, amount: null, }; } else { } - - // let shouldProrate = - // price.config.proration_config?.on_decrease == OnDecrease.Prorate; - - // if (shouldProrate) { - // invoice = await createDowngradeProrationInvoice({ - // org, - // cusPrice, - // stripeCli, - // sub, - // newPrice, - // prevPrice, - // newRoundedUsage, - // feature, - // product, - // onDecrease, - // logger, - // }); - // } }; diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts deleted file mode 100644 index aadc52f4c..000000000 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts +++ /dev/null @@ -1,264 +0,0 @@ -// import { -// ErrCode, -// type Feature, -// getFeatureName, -// Infinite, -// numberWithCommas, -// type ProductItem, -// ProductItemFeatureType, -// type ProductItemInterval, -// } from "@autumn/shared"; -// import RecaseError from "@/utils/errorUtils.js"; -// import { formatAmount } from "@/utils/formatUtils.js"; -// import { notNullish, nullish } from "@/utils/genUtils.js"; - -// export const getIntervalString = ({ -// interval, -// intervalCount, -// prefix = "per ", -// }: { -// interval: ProductItemInterval; -// intervalCount?: number | null; -// prefix?: string; -// }) => { -// if (!interval) return ""; -// if (intervalCount === 1) { -// return `${prefix}${interval}`; -// } -// return `${prefix}${intervalCount} ${interval}s`; -// }; - -// export const formatTiers = ({ -// item, -// currency, -// }: { -// item: ProductItem; -// currency?: string | null; -// }) => { -// const tiers = item.tiers; -// if (tiers) { -// if (tiers.length === 1) { -// return formatAmount({ -// currency, -// amount: tiers[0].amount, -// maxFractionDigits: 10, -// }); -// } - -// const firstPrice = tiers[0].amount; -// const lastPrice = tiers[tiers.length - 1].amount; - -// return `${formatAmount({ -// currency, -// amount: firstPrice, -// maxFractionDigits: 10, -// })} - ${formatAmount({ -// currency, -// amount: lastPrice, -// maxFractionDigits: 10, -// })}`; -// } -// }; - -// // export const getIncludedFeatureName = ({ -// // item, -// // feature, -// // }: { -// // item: ProductItem; -// // feature: Feature; -// // }) => { -// // return getFeatureName({ -// // feature, -// // plural: typeof item.included_usage === "number" && item.included_usage > 1, -// // }); -// // }; - -// export const getFeatureItemDisplay = ({ -// item, -// feature, -// }: { -// item: ProductItem; -// feature?: Feature; -// }) => { -// if (!feature) { -// throw new RecaseError({ -// message: `Feature ${item.feature_id} not found`, -// code: ErrCode.FeatureNotFound, -// statusCode: 404, -// }); -// } -// // 1. If feature -// if (item.feature_type === ProductItemFeatureType.Static) { -// return { -// primary_text: getFeatureName({ -// feature, -// plural: false, -// capitalize: true, -// }), -// }; -// } - -// const featureName = getFeatureName({ -// feature, -// units: item.included_usage, -// }); - -// const includedUsageTxt = -// item.included_usage === Infinite -// ? "Unlimited " -// : nullish(item.included_usage) || item.included_usage === 0 -// ? "" -// : `${numberWithCommas(item.included_usage!)} `; - -// return { -// primary_text: `${includedUsageTxt}${featureName}`, -// secondary_text: undefined, -// }; -// }; - -// export const getPriceItemDisplay = ({ -// item, -// currency, -// }: { -// item: ProductItem; -// currency?: string | null; -// }) => { -// const primaryText = formatAmount({ -// currency, -// amount: item.price as number, -// }); -// const intervalStr = getIntervalString({ -// interval: item.interval!, -// intervalCount: item.interval_count, -// }); - -// const secondaryText = intervalStr || undefined; - -// return { -// primary_text: primaryText, -// secondary_text: secondaryText, -// }; -// }; - -// export const getFeaturePriceItemDisplay = ({ -// feature, -// item, -// currency, -// isMainPrice = false, -// minifyIncluded = false, -// }: { -// feature?: Feature; -// item: ProductItem; -// currency?: string | null; -// isMainPrice?: boolean; -// minifyIncluded?: boolean; -// }) => { -// if (!feature) { -// throw new RecaseError({ -// message: `Feature ${item.feature_id} not found`, -// code: ErrCode.FeatureNotFound, -// statusCode: 404, -// }); -// } - -// // 1. Get included usage -// const includedFeatureName = getFeatureName({ -// feature, -// units: item.included_usage, -// }); - -// const includedUsage = item.included_usage as number | null; -// let includedUsageStr = ""; -// if (notNullish(includedUsage) && includedUsage! > 0) { -// if (minifyIncluded) { -// includedUsageStr = `${numberWithCommas(includedUsage!)} included`; -// } else { -// includedUsageStr = `${numberWithCommas(includedUsage!)} ${includedFeatureName}`; -// } -// } - -// const priceStr = formatTiers({ item, currency }); -// const billingFeatureName = getFeatureName({ -// feature, -// units: item.billing_units, -// }); - -// let priceStr2 = ""; -// if (item.billing_units && item.billing_units > 1) { -// priceStr2 = `${numberWithCommas(item.billing_units)} ${billingFeatureName}`; -// } else { -// priceStr2 = `${billingFeatureName}`; -// } - -// // let intervalStr = isMainPrice && item.interval ? ` per ${item.interval}` : ""; -// const intervalStr = isMainPrice -// ? getIntervalString({ -// interval: item.interval!, -// intervalCount: item.interval_count, -// }) -// : ""; - -// // console.log("isMainPrice", isMainPrice); -// // console.log("intervalStr", intervalStr); - -// if (includedUsageStr) { -// return { -// primary_text: includedUsageStr, -// secondary_text: `then ${priceStr} per ${priceStr2}${intervalStr}`, -// }; -// } - -// if (isMainPrice) { -// return { -// primary_text: priceStr + ` per ${priceStr2}`, -// secondary_text: `${intervalStr}`, -// }; -// } - -// // ${intervalStr} -// return { -// primary_text: priceStr + ` per ${priceStr2}`, -// // secondary_text: `per ${priceStr2}${intervalStr}`, -// secondary_text: "", -// }; -// }; - -// export const getProductItemDisplay = ({ -// item, -// features, -// currency = "usd", -// isMainPrice = false, -// }: { -// item: ProductItem; -// features: Feature[]; -// currency?: string | null; -// isMainPrice?: boolean; -// }) => { -// if (isFeatureItem(item)) { -// return getFeatureItemDisplay({ -// item, -// feature: features.find((f) => f.id === item.feature_id), -// }); -// } - -// if (isPriceItem(item)) { -// return getPriceItemDisplay({ -// item, -// currency, -// }); -// } - -// if (isFeaturePriceItem(item)) { -// return getFeaturePriceItemDisplay({ -// item, -// feature: features.find((f) => f.id === item.feature_id), -// currency, -// isMainPrice, -// }); -// } - -// return { -// primary_text: "couldn't detect item type", -// secondary_text: "", -// }; -// }; diff --git a/server/tests/archives/coupon1 copy.ts b/server/tests/archives/coupon1 copy.ts deleted file mode 100644 index 6d373d6ec..000000000 --- a/server/tests/archives/coupon1 copy.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { type Customer, LegacyVersion } from "@autumn/shared"; -import { expect } from "chai"; -import chalk from "chalk"; -import { addHours, addMonths } from "date-fns"; -import type Stripe from "stripe"; -import { setupBefore } from "@tests/before.js"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { features, products, rewards } from "@tests/global.js"; -import { compareMainProduct } from "@tests/utils/compare.js"; -import { getFixedPriceAmount, timeout } from "@tests/utils/genUtils.js"; -import { - advanceClockForInvoice, - advanceTestClock, - completeCheckoutForm, - getDiscount, -} from "@tests/utils/stripeUtils.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { getPriceForOverage } from "@/internal/products/prices/priceUtils.js"; -import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; - -const testCase = "coupon1"; - -describe( - chalk.yellow(`${testCase} -- Testing one-off rollover, apply to all`), - () => { - const customerId = "coupon1"; - let stripeCli: Stripe; - let customer: Customer; - let testClockId: string; - let db, org, env; - - let autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let couponAmount = rewards.rolloverAll.discount_config.discount_value; - - before(async function () { - await setupBefore(this); - db = this.db; - org = this.org; - env = this.env; - autumn = this.autumnJs; - stripeCli = this.stripeCli; - - const res = await initCustomer({ - customerId, - org: this.org, - env: this.env, - db: this.db, - autumn: this.autumnJs, - }); - - testClockId = res.testClockId; - customer = res.customer; - }); - - // CYCLE 0 - it("CYCLE 0: should attach pro with overage (through checkout)", async () => { - couponAmount -= getFixedPriceAmount(products.proWithOverage); - - const res = await AutumnCli.attach({ - customerId, - productId: products.proWithOverage.id, - forceCheckout: true, - }); - - await completeCheckoutForm( - res.checkout_url, - undefined, - rewards.rolloverAll.id, - ); - - await timeout(20000); - - const cusRes = await AutumnCli.getCustomer(customerId); - compareMainProduct({ - sent: products.proWithOverage, - cusRes, - }); - }); - - it("CYCLE 0: should have $0 invoice and correct remaining coupon amount", async () => { - const cusRes = await AutumnCli.getCustomer(customerId); - expect(cusRes.invoices[0].total).to.equal(0); - - const cusDiscount = await getDiscount({ - stripeCli: stripeCli, - customer: cusRes.customer, - }); - - try { - expect(getOriginalCouponId(cusDiscount.coupon?.id)).to.equal( - rewards.rolloverAll.id, - ); - - // Expect amount to be original amount - pro price - expect(cusDiscount.coupon?.amount_off).to.equal(couponAmount * 100); - } catch (error) { - console.error("--------------------------------"); - console.error( - "Expected stripe cus to have coupon", - rewards.rolloverAll, - ); - console.error("Actual stripe cus discount", cusDiscount); - throw error; - } - }); - - // CYCLE 1 - it("CYCLE 1: should set usage to -100 and advance clock by 1 month", async () => { - const usage = 100; - const res = await AutumnCli.usage({ - customerId, - featureId: features.metered1.id, - value: usage, - }); - - // Price - const price = getPriceForOverage( - products.proWithOverage.prices[1], - -(products.proWithOverage.entitlements.metered1.allowance! - usage), - ); - - couponAmount = - couponAmount - (price + getFixedPriceAmount(products.proWithOverage)); - - await advanceClockForInvoice({ - stripeCli, - testClockId, - waitForMeterUpdate: true, - }); - }); - - it("CYCLE 1: should have $0 invoice and correct new coupon amount", async () => { - const cusRes = await AutumnCli.getCustomer(customerId); - expect(cusRes.invoices[0].total).to.equal(0); - - const cusDiscount = await getDiscount({ - stripeCli: stripeCli, - customer, - }); - - try { - expect(getOriginalCouponId(cusDiscount.coupon?.id)).to.equal( - rewards.rolloverAll.id, - ); - expect(cusDiscount.coupon?.amount_off).to.equal(couponAmount * 100); - } catch (error) { - console.log("--------------------------------"); - console.log("coupon1, cycle 1 failed"); - console.log("Expected stripe cus to have coupon", rewards.rolloverAll); - console.log("Actual stripe cus discount", cusDiscount); - throw error; - } - }); - - // CYCLE 2 - it("CYCLE 2: should have $0 invoice and correct new coupon amount after 2nd cycle", async () => { - await timeout(20000); - const advanceTo = addHours(addMonths(new Date(), 2), 2); - await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: advanceTo.getTime(), - }); - - const cusDiscount = await getDiscount({ - stripeCli: stripeCli, - customer, - }); - - const newCouponAmount = - couponAmount - getFixedPriceAmount(products.proWithOverage); - - try { - expect(cusDiscount.coupon?.amount_off).to.equal(newCouponAmount * 100); - } catch (error) { - console.log("--------------------------------"); - console.log("coupon1, cycle 2 failed"); - console.log("Expected coupon amount", newCouponAmount * 100); - console.log("Stripe cus discount", cusDiscount); - throw error; - } - }); - }, -); diff --git a/shared/agents.md b/shared/agents.md new file mode 100644 index 000000000..a24c0d0ee --- /dev/null +++ b/shared/agents.md @@ -0,0 +1,8 @@ +# Agent Guidelines for shared/ + +## Code Organization + +- Do NOT create `index.ts` files in folders. Import directly from the source file. + + + diff --git a/shared/index.ts b/shared/index.ts index 0671f88c2..1a928463c 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -100,6 +100,7 @@ export * from "./api/events/insights/query/insightsQueryBody.js"; export * from "./models/attachModels/attachFunctionResponse.js"; export * from "./models/billingModels/cusProductActions.js"; export * from "./models/billingModels/insertFullCusProductContext.js"; +export * from "./models/billingModels/invoicingModels/lineItem.js"; // Billing Models export * from "./models/billingModels/newProductAction.js"; export * from "./models/migrationModels/migrationErrorTable.js"; @@ -173,6 +174,7 @@ export * from "./models/subModels/subTable.js"; // Billing utils export * from "./utils/billingUtils/index"; // Utils +export * from "./utils/common/formatUtils/formatInterval.js"; export * from "./utils/displayUtils.js"; export * from "./utils/index.js"; export * from "./utils/intervalUtils.js"; diff --git a/shared/models/billingModels/invoicingModels/lineItem.ts b/shared/models/billingModels/invoicingModels/lineItem.ts new file mode 100644 index 000000000..a788f891a --- /dev/null +++ b/shared/models/billingModels/invoicingModels/lineItem.ts @@ -0,0 +1,6 @@ +export type LineItem = { + amount: number; + description: string; + price_id: string; + feature_id?: string; +}; diff --git a/shared/utils/billingUtils/INVOICING_ARCHITECTURE.md b/shared/utils/billingUtils/INVOICING_ARCHITECTURE.md index 72ba76e97..093abad47 100644 --- a/shared/utils/billingUtils/INVOICING_ARCHITECTURE.md +++ b/shared/utils/billingUtils/INVOICING_ARCHITECTURE.md @@ -177,3 +177,5 @@ export const getUpgradeLineItems = async ({ | `server/.../billing/invoicing/` | Stripe ops, orchestration | invoicingUtils, Stripe | | `server/.../billing/invoicing/scenarioLineItems/` | Scenario-specific logic | invoicingUtils, Stripe | + + diff --git a/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts b/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts index ce18a5666..13c771723 100644 --- a/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts +++ b/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts @@ -1,7 +1,7 @@ import { UTCDate } from "@date-fns/utc"; import type { BillingInterval } from "@models/productModels/intervals/billingInterval"; import type { EntInterval } from "@models/productModels/intervals/entitlementInterval"; -import { formatMs } from "../../common/timeUtils"; +import { formatMs } from "../../common/formatUtils/formatUnix"; import { getCycleIntervalFunctions } from "./getCycleIntervalFunctions"; /** diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 3c7a2d850..3b665e78a 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -1,6 +1,6 @@ -export * from "./cycleUtils/getCycleEnd.js"; -export * from "./cycleUtils/getCycleStart.js"; -export * from "./intervalUtils/intervalArithmetic.js"; - -export * from "./invoicingUtils/lineItemUtils/priceToLineAmount.js"; -export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount.js"; +export * from "./cycleUtils/getCycleEnd"; +export * from "./cycleUtils/getCycleStart"; +export * from "./intervalUtils/intervalArithmetic"; +export * from "./invoicingUtils/lineItemBuilders/consumableToLineItem"; +export * from "./invoicingUtils/lineItemUtils/priceToLineAmount"; +export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount"; diff --git a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/featureUsageToDescription.ts b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/featureUsageToDescription.ts new file mode 100644 index 000000000..d39dc6a70 --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/featureUsageToDescription.ts @@ -0,0 +1,20 @@ +import type { Feature } from "../../../../models/featureModels/featureModels"; +import { getSingularAndPlural, numberWithCommas } from "../../../displayUtils"; + +/** + * Generates base usage description for a feature. + * Examples: "150 credits", "1 user", "1,500 API calls" + */ +export const featureUsageToDescription = ({ + feature, + usage, +}: { + feature: Feature; + usage: number; +}): string => { + const { singular, plural } = getSingularAndPlural({ feature }); + const usageStr = numberWithCommas(Math.ceil(usage)); + const featureName = usage === 1 ? singular : plural; + + return `${usageStr} ${featureName}`; +}; diff --git a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/fixedPriceToLineDescription.ts b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/fixedPriceToLineDescription.ts new file mode 100644 index 000000000..72901542a --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/fixedPriceToLineDescription.ts @@ -0,0 +1,28 @@ +import { BillingInterval } from "../../../../models/productModels/intervals/billingInterval"; +import type { FixedPriceConfig } from "../../../../models/productModels/priceModels/priceConfig/fixedPriceConfig"; +import type { Price } from "../../../../models/productModels/priceModels/priceModels"; +import { formatAmount } from "../../../common/formatUtils/formatAmount"; +import { formatInterval } from "../../../common/formatUtils/formatInterval"; + +export const fixedPriceToDescription = ({ + price, + currency, +}: { + price: Price; // must be fixed price + currency?: string; +}): string => { + const config = price.config as FixedPriceConfig; + const amount = formatAmount({ currency, amount: config.amount }); + + if (config.interval === BillingInterval.OneOff) { + return amount; + } + + const intervalStr = formatInterval({ + interval: config.interval, + intervalCount: config.interval_count || 1, + prefix: "", + }); + + return `${amount} / ${intervalStr}`; // "$10 / month" +}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/consumableToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/consumableToLineItem.ts new file mode 100644 index 000000000..6ca7034f3 --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/consumableToLineItem.ts @@ -0,0 +1,50 @@ +import type { LineItem } from "../../../../models/billingModels/invoicingModels/lineItem"; +import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { cusEntToTotalOverage } from "../../../cusEntUtils/overageUtils/cusEntsToTotalOverage"; +import { getFeatureInvoiceDescription } from "../../../displayUtils"; +import { cusEntToCusPrice } from "../../../productUtils/convertUtils"; +import { tiersToLineAmount } from "../lineItemUtils/tiersToLineAmount"; + +/** + * Creates a line item for a consumable (UsageInArrear) price. + * Returns null if there's no overage to charge. + */ +export const consumableToLineItem = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}): LineItem | null => { + // 1. Get the related price + const cusPrice = cusEntToCusPrice({ cusEnt }); + if (!cusPrice) return null; + + const price = cusPrice.price; + const billingUnits = price.config.billing_units ?? 1; + + // 2. Get overage from cusEnt balance + const overage = cusEntToTotalOverage({ cusEnt }); + if (overage <= 0) return null; + + // 3. Calculate amount using tiers + const amount = tiersToLineAmount({ price, overage, billingUnits }); + + // 4. Calculate total usage for description + const allowance = cusEnt.entitlement.allowance ?? 0; + const balance = cusEnt.balance ?? 0; + const usage = allowance - balance; + + // 5. Generate description + const description = getFeatureInvoiceDescription({ + feature: cusEnt.entitlement.feature, + usage, + billingUnits, + prodName: cusEnt.customer_product.product.name, + }); + + return { + amount, + description, + price_id: price.id, + feature_id: cusEnt.entitlement.feature_id, + }; +}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts index 8dff47b5c..95a6b4f01 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts @@ -1,8 +1,8 @@ import { - isFixedPrice, - nullish, - type Price, - tiersToLineAmount, + isFixedPrice, + nullish, + type Price, + tiersToLineAmount, } from "@autumn/shared"; import { Decimal } from "decimal.js"; /** @@ -12,31 +12,32 @@ import { Decimal } from "decimal.js"; * @param overage - Overage amount (for prepaid or usage-in-arrear prices) * @param multiplier - Quantity multiplier (for fixed prices, e.g. 3 seats) */ + export const priceToLineAmount = ({ - price, - overage, - multiplier = 1, + price, + overage, + multiplier = 1, }: { - price: Price; - overage?: number; - multiplier?: number; + price: Price; + overage?: number; + multiplier?: number; }): number => { - // Fixed prices: flat amount × multiplier - if (isFixedPrice(price)) { - const config = price.config; - return new Decimal(config.amount).mul(multiplier).toNumber(); - } + // Fixed prices: flat amount × multiplier + if (isFixedPrice(price)) { + const config = price.config; + return new Decimal(config.amount).mul(multiplier).toNumber(); + } - // Usage-based prices: tiered calculation - if (nullish(overage)) { - throw new Error( - `[priceToLineAmount] overage required for usage-based prices`, - ); - } + // Usage-based prices: tiered calculation + if (nullish(overage)) { + throw new Error( + `[priceToLineAmount] overage required for usage-based prices`, + ); + } - return tiersToLineAmount({ - price, - overage, - billingUnits: price.config.billing_units ?? 1, - }); + return tiersToLineAmount({ + price, + overage, + billingUnits: price.config.billing_units ?? 1, + }); }; diff --git a/server/src/utils/formatUtils.ts b/shared/utils/common/formatUtils/formatAmount.ts similarity index 56% rename from server/src/utils/formatUtils.ts rename to shared/utils/common/formatUtils/formatAmount.ts index 62a44b528..d314755d0 100644 --- a/server/src/utils/formatUtils.ts +++ b/shared/utils/common/formatUtils/formatAmount.ts @@ -1,22 +1,25 @@ -import type { Organization } from "@autumn/shared"; +import type { Organization } from "../../../models/orgModels/orgTable"; export const formatAmount = ({ org, currency, amount, - maxFractionDigits = 2, + maxFractionDigits = 10, minFractionDigits = 0, + amountFormatOptions, }: { org?: Organization; currency?: string | null; amount: number; maxFractionDigits?: number; minFractionDigits?: number; + amountFormatOptions?: Intl.NumberFormatOptions; }) => { return new Intl.NumberFormat(undefined, { style: "currency", currency: currency || org?.default_currency || "USD", - minimumFractionDigits: minFractionDigits || 0, - maximumFractionDigits: maxFractionDigits || 2, + minimumFractionDigits: minFractionDigits, + maximumFractionDigits: maxFractionDigits, + ...amountFormatOptions, }).format(amount); }; diff --git a/shared/utils/common/formatUtils/formatInterval.ts b/shared/utils/common/formatUtils/formatInterval.ts new file mode 100644 index 000000000..615936a0a --- /dev/null +++ b/shared/utils/common/formatUtils/formatInterval.ts @@ -0,0 +1,42 @@ +import { BillingInterval } from "../../../models/productModels/intervals/billingInterval"; +import { EntInterval } from "../../../models/productModels/intervals/entitlementInterval"; +import { ProductItemInterval } from "../../../models/productModels/intervals/productItemInterval"; + +type IntervalType = BillingInterval | EntInterval | ProductItemInterval; + +export const formatInterval = ({ + interval, + intervalCount = 1, + prefix = "per ", +}: { + interval?: IntervalType; + intervalCount?: number; + prefix?: string; +}): string => { + if (!interval) return ""; + + // Handle one_off and lifetime (no interval string) + if ( + interval === BillingInterval.OneOff || + interval === EntInterval.Lifetime + ) { + return ""; + } + + let intervalStr: string = interval; + + // Handle special case for semi_annual + if ( + interval === BillingInterval.SemiAnnual || + interval === EntInterval.SemiAnnual || + interval === ProductItemInterval.SemiAnnual + ) { + intervalStr = "half year"; + } + + if (intervalCount === 1) { + return `${prefix}${intervalStr}`; + } + + return `${prefix}${intervalCount} ${intervalStr}s`; +}; diff --git a/shared/utils/common/timeUtils.ts b/shared/utils/common/formatUtils/formatUnix.ts similarity index 100% rename from shared/utils/common/timeUtils.ts rename to shared/utils/common/formatUtils/formatUnix.ts diff --git a/shared/utils/common/formatUtils/index.ts b/shared/utils/common/formatUtils/index.ts new file mode 100644 index 000000000..9ea21f51f --- /dev/null +++ b/shared/utils/common/formatUtils/index.ts @@ -0,0 +1,3 @@ +export * from "./formatAmount"; +export * from "./formatInterval"; +export * from "./formatUnix"; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts new file mode 100644 index 000000000..81b69b70e --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts @@ -0,0 +1,25 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { + cusEntToCusPrice, + entToOptions, +} from "../../productUtils/convertUtils"; +import { getStartingBalance } from "../getStartingBalance"; + +export const cusEntToStartingBalance = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}) => { + const cusPrice = cusEntToCusPrice({ cusEnt }); + const price = cusPrice?.price; + const options = entToOptions({ + ent: cusEnt.entitlement, + options: cusEnt.customer_product.options, + }); + + return getStartingBalance({ + entitlement: cusEnt.entitlement, + options, + relatedPrice: price, + }); +}; diff --git a/shared/utils/cusEntUtils/overageUtils/cusEntsToTotalOverage.ts b/shared/utils/cusEntUtils/overageUtils/cusEntsToTotalOverage.ts new file mode 100644 index 000000000..4b9d44e60 --- /dev/null +++ b/shared/utils/cusEntUtils/overageUtils/cusEntsToTotalOverage.ts @@ -0,0 +1,25 @@ +import { Decimal } from "decimal.js"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { isEntityScopedCusEnt } from "../classifyCusEntUtils"; + +export const cusEntToTotalOverage = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}) => { + // 1. If entity scoped + if (isEntityScopedCusEnt({ cusEnt })) { + let totalOverage = new Decimal(0); + for (const [_, entity] of Object.entries(cusEnt.entities || {})) { + const overage = Decimal.max(0, new Decimal(-entity.balance)); + + totalOverage = totalOverage.add(overage); + } + + return totalOverage.toNumber(); + } + + // 2. If not entity scoped + const overage = Decimal.max(0, new Decimal(-(cusEnt.balance || 0))); + return overage.toNumber(); +}; diff --git a/shared/utils/displayUtils.ts b/shared/utils/displayUtils.ts index de0eeafe1..d757dab14 100644 --- a/shared/utils/displayUtils.ts +++ b/shared/utils/displayUtils.ts @@ -1,6 +1,5 @@ import { format } from "date-fns"; import type { Feature } from "../models/featureModels/featureModels.js"; -import type { Organization } from "../models/orgModels/orgTable.js"; import { notNullish, nullish } from "./utils.js"; export const getFeatureName = ({ feature, @@ -144,27 +143,3 @@ export const getFeatureInvoiceDescription = ({ return result; }; - -export const formatAmount = ({ - org, - currency, - amount, - maxFractionDigits = 10, - minFractionDigits = 0, - amountFormatOptions, -}: { - org?: Organization; - currency?: string | null; - amount: number; - maxFractionDigits?: number; - minFractionDigits?: number; - amountFormatOptions?: Intl.NumberFormatOptions; -}) => { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency: currency || org?.default_currency || "USD", - minimumFractionDigits: minFractionDigits, - maximumFractionDigits: maxFractionDigits, - ...amountFormatOptions, - }).format(amount); -}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index e35620067..a964f6993 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -5,9 +5,9 @@ export * from "./billingUtils/resolveAttachUtils/resolveAttachActions.js"; export * from "./billingUtils/resolveAttachUtils/resolveNewProductTiming.js"; export * from "./billingUtils/resolveAttachUtils/resolveOngoingCusProductAction.js"; export * from "./billingUtils/resolveAttachUtils/resolveScheduledCusProductAction.js"; -export * from "./common/timeUtils.js"; +// Common utils +export * from "./common/formatUtils/index.js"; export * from "./common/unixUtils.js"; - export * from "./cusEntUtils/balanceUtils/cusEntsToBalance.js"; export * from "./cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.js"; export * from "./cusEntUtils/balanceUtils/cusEntToPurchasedBalance.js"; diff --git a/shared/utils/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 572eda7d6..c96ae8a11 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -1,13 +1,10 @@ import { FeatureType } from "../models/featureModels/featureEnums.js"; import type { Feature } from "../models/featureModels/featureModels.js"; -import { ProductItemInterval } from "../models/productModels/intervals/productItemInterval.js"; import { Infinite } from "../models/productModels/productEnums.js"; import type { ProductItem } from "../models/productV2Models/productItemModels/productItemModels.js"; -import { - formatAmount, - getFeatureName, - numberWithCommas, -} from "./displayUtils.js"; +import { formatAmount } from "./common/formatUtils/formatAmount.js"; +import { formatInterval } from "./common/formatUtils/formatInterval.js"; +import { getFeatureName, numberWithCommas } from "./displayUtils.js"; import { isFeatureItem, isFeaturePriceItem, @@ -49,28 +46,6 @@ export const formatTiers = ({ } }; -export const getIntervalString = ({ - interval, - intervalCount = 1, - prefix = "per ", -}: { - interval: ProductItemInterval | null | undefined; - intervalCount?: number | null; - prefix?: string; -}) => { - let intervalStr: string = interval || ""; - - if (interval === ProductItemInterval.SemiAnnual) { - intervalStr = "half year"; - } - - if (!interval) return ""; - if (intervalCount === 1) { - return `${prefix}${intervalStr}`; - } - return `${prefix}${intervalCount} ${intervalStr}s`; -}; - export const getFeatureItemDisplay = ({ item, feature, @@ -98,9 +73,9 @@ export const getFeatureItemDisplay = ({ ? "0 " : `${numberWithCommas(item.included_usage)} `; - const intervalStr = getIntervalString({ - interval: item.interval, - intervalCount: item.interval_count, + const intervalStr = formatInterval({ + interval: item.interval ?? undefined, + intervalCount: item.interval_count ?? undefined, }); return { @@ -121,9 +96,9 @@ export const getPriceItemDisplay = ({ amount: item.price as number, }); - const intervalStr = getIntervalString({ - interval: item.interval, - intervalCount: item.interval_count, + const intervalStr = formatInterval({ + interval: item.interval ?? undefined, + intervalCount: item.interval_count ?? undefined, }); const secondaryText = intervalStr || undefined; @@ -184,9 +159,9 @@ export const getFeaturePriceItemDisplay = ({ // let intervalStr = isMainPrice && item.interval ? ` per ${item.interval}` : ""; const intervalStr = isMainPrice || fullDisplay - ? getIntervalString({ - interval: item.interval, - intervalCount: item.interval_count, + ? formatInterval({ + interval: item.interval ?? undefined, + intervalCount: item.interval_count ?? undefined, }) : ""; diff --git a/vite/src/utils/formatUtils/formatTextUtils.ts b/vite/src/utils/formatUtils/formatTextUtils.ts index 85f53ad87..90689ed8e 100644 --- a/vite/src/utils/formatUtils/formatTextUtils.ts +++ b/vite/src/utils/formatUtils/formatTextUtils.ts @@ -29,23 +29,6 @@ export const slugify = ( .replace(/[^\w\s-]/g, ""); }; -export const formatAmount = ({ - amount, - currency, - maxFractionDigits = 10, -}: { - amount: number; - currency: string; - maxFractionDigits?: number; -}) => { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: currency, - minimumFractionDigits: 0, - maximumFractionDigits: maxFractionDigits, - }).format(amount); -}; - export const formatIntervalText = ({ interval, intervalCount, diff --git a/vite/src/utils/product/product-item/formatProductItem.ts b/vite/src/utils/product/product-item/formatProductItem.ts index e2e071674..bd0fe035e 100644 --- a/vite/src/utils/product/product-item/formatProductItem.ts +++ b/vite/src/utils/product/product-item/formatProductItem.ts @@ -1,34 +1,18 @@ import { - BillingInterval, - Feature, + type Feature, FeatureType, - FrontendOrg, + type FrontendOrg, + formatAmount, + formatInterval, Infinite, - Organization, - ProductItem, + type ProductItem, ProductItemType, } from "@autumn/shared"; -import { formatAmount, getItemType, intervalIsNone } from "../productItemUtils"; -import { getFeature } from "../entitlementUtils"; import { notNullish } from "@/utils/genUtils"; -import { ProductItemInterval } from "autumn-js"; - -const getIntervalString = ({ - interval, - intervalCount = 1, -}: { - interval: ProductItemInterval; - intervalCount?: number | null; -}) => { - if (!interval) return ""; - - if (intervalCount == 1) { - return `per ${interval}`; - } - - return `per ${intervalCount} ${interval}s`; -}; +import { getFeature } from "../entitlementUtils"; +import { getItemType, intervalIsNone } from "../productItemUtils"; +// Can probably delete this... export const getPaidFeatureString = ({ item, currency = "USD", @@ -42,34 +26,34 @@ export const getPaidFeatureString = ({ if (item.price) { amountStr = formatAmount({ - defaultCurrency: currency, + currency, amount: item.price, }); - } else if (item.tiers && item.tiers.length == 1) { + } else if (item.tiers && item.tiers.length === 1) { amountStr = formatAmount({ - defaultCurrency: currency, - amount: item.tiers![0].amount, + currency, + amount: item.tiers[0].amount, }); - } else { + } else if (item.tiers) { amountStr = `${formatAmount({ - defaultCurrency: currency, - amount: item.tiers![0].amount, + currency, + amount: item.tiers[0].amount, })} - ${formatAmount({ - defaultCurrency: currency, - amount: item.tiers![item.tiers!.length - 1].amount, + currency, + amount: item.tiers[item.tiers.length - 1].amount, })}`; } - const feature = features.find((f: Feature) => f.id == item.feature_id); + const feature = features.find((f: Feature) => f.id === item.feature_id); amountStr += ` per ${item.billing_units! > 1 ? item.billing_units : ""} ${ feature?.name }`; if (!intervalIsNone(item.interval)) { - const intervalStr = getIntervalString({ - interval: item.interval!, - intervalCount: item.interval_count, + const intervalStr = formatInterval({ + interval: item.interval ?? undefined, + intervalCount: item.interval_count ?? undefined, }); amountStr += ` ${intervalStr}`; } @@ -89,14 +73,14 @@ const getFixedPriceString = ({ currency?: string; }) => { const formattedAmount = formatAmount({ - defaultCurrency: currency, + currency, amount: item.price!, }); if (!intervalIsNone(item.interval)) { - const intervalStr = getIntervalString({ - interval: item.interval!, - intervalCount: item.interval_count, + const intervalStr = formatInterval({ + interval: item.interval ?? undefined, + intervalCount: item.interval_count ?? undefined, }); return `${formattedAmount} ${intervalStr}`; } @@ -121,9 +105,9 @@ export const getFeatureString = ({ return `Unlimited ${feature?.name}`; } - const intervalStr = getIntervalString({ - interval: item.interval!, - intervalCount: item.interval_count, + const intervalStr = formatInterval({ + interval: item.interval ?? undefined, + intervalCount: item.interval_count ?? undefined, }); return `${item.included_usage ?? 0} ${feature?.name}${item.entity_feature_id ? ` per ${getFeature(item.entity_feature_id, features)?.name}` : ""}${notNullish(item.interval) ? ` ${intervalStr}` : ""}`; diff --git a/vite/src/utils/product/productItemUtils.ts b/vite/src/utils/product/productItemUtils.ts index 2f7922edf..0d8bf6631 100644 --- a/vite/src/utils/product/productItemUtils.ts +++ b/vite/src/utils/product/productItemUtils.ts @@ -17,23 +17,6 @@ export const itemIsUnlimited = (item: ProductItem) => { return item.included_usage === Infinite; }; -export const formatAmount = ({ - defaultCurrency, - amount, - maxFractionDigits = 6, -}: { - defaultCurrency: string; - amount: number; - maxFractionDigits?: number; -}) => { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: defaultCurrency, - minimumFractionDigits: 0, - maximumFractionDigits: maxFractionDigits || 6, - }).format(amount); -}; - export const getItemType = (item: ProductItem) => { if (isPriceItem(item)) { return ProductItemType.Price; diff --git a/vite/src/views/customers/customer/product/components/attach-preview/AdjustQuantity.tsx b/vite/src/views/customers/customer/product/components/attach-preview/AdjustQuantity.tsx index 03f9cbf44..d6bb6609b 100644 --- a/vite/src/views/customers/customer/product/components/attach-preview/AdjustQuantity.tsx +++ b/vite/src/views/customers/customer/product/components/attach-preview/AdjustQuantity.tsx @@ -1,11 +1,11 @@ +import { formatAmount } from "@autumn/shared"; +import React from "react"; import { PriceItem, QuantityInput, } from "@/components/pricing/attach-pricing-dialog"; import { useOrg } from "@/hooks/common/useOrg"; -import { formatAmount } from "@/utils/product/productItemUtils"; import { useProductContext } from "@/views/products/product/ProductContext"; -import React from "react"; export const AdjustableOptions = () => { const { org } = useOrg(); @@ -37,7 +37,7 @@ export const AdjustableOptions = () => { ×{" "} {formatAmount({ - defaultCurrency: currency, + currency, amount: price, maxFractionDigits: 2, })}{" "} diff --git a/vite/src/views/customers/customer/product/components/attach-preview/DueNextCycle.tsx b/vite/src/views/customers/customer/product/components/attach-preview/DueNextCycle.tsx index b317057c3..9be945c3a 100644 --- a/vite/src/views/customers/customer/product/components/attach-preview/DueNextCycle.tsx +++ b/vite/src/views/customers/customer/product/components/attach-preview/DueNextCycle.tsx @@ -1,6 +1,7 @@ import { AttachBranch, type Feature, + formatAmount, getAmountForQuantity, getFeatureInvoiceDescription, type Price, @@ -9,7 +10,6 @@ import { PriceItem } from "@/components/pricing/attach-pricing-dialog"; import { useOrg } from "@/hooks/common/useOrg"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils"; -import { formatAmount } from "@/utils/formatUtils/formatTextUtils"; import { useProductContext } from "@/views/products/product/ProductContext"; import { AdjustableOptions } from "./AdjustQuantity"; diff --git a/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx b/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx index 693cdab97..ff822d0fb 100644 --- a/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx +++ b/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx @@ -1,19 +1,15 @@ -import { QuantityInput } from "@/components/pricing/attach-pricing-dialog"; - -import { useProductContext } from "@/views/products/product/ProductContext"; -import { AttachNewItems } from "./AttachNewItems"; -import { PriceItem } from "@/components/pricing/attach-pricing-dialog"; -import { formatAmount } from "@/utils/product/productItemUtils"; import { AttachBranch, + formatAmount, getAmountForQuantity, - Price, - UsagePriceConfig, + type Price, } from "@autumn/shared"; import { Decimal } from "decimal.js"; +import { PriceItem } from "@/components/pricing/attach-pricing-dialog"; import { Input } from "@/components/ui/input"; -import { notNullish } from "@/utils/genUtils"; import { useOrg } from "@/hooks/common/useOrg"; +import { notNullish } from "@/utils/genUtils"; +import { useProductContext } from "@/views/products/product/ProductContext"; export const DueToday = () => { const { org } = useOrg(); @@ -82,7 +78,7 @@ export const DueToday = () => { if (notNullish(option.price)) { return `x ${formatAmount({ amount: option.price, - defaultCurrency: currency, + currency, maxFractionDigits: 5, })} per `; } @@ -161,7 +157,7 @@ export const DueToday = () => { {formatAmount({ amount: getTotalPrice(), - defaultCurrency: currency, + currency, maxFractionDigits: 2, })} diff --git a/vite/src/views/customers/customer/product/components/attach-preview/OptionsResult.tsx b/vite/src/views/customers/customer/product/components/attach-preview/OptionsResult.tsx index 9a2c988c5..f39bd7f00 100644 --- a/vite/src/views/customers/customer/product/components/attach-preview/OptionsResult.tsx +++ b/vite/src/views/customers/customer/product/components/attach-preview/OptionsResult.tsx @@ -1,8 +1,7 @@ +import { AttachBranch, formatAmount } from "@autumn/shared"; import { PriceItem } from "@/components/pricing/attach-pricing-dialog"; import { useOrg } from "@/hooks/common/useOrg"; -import { formatAmount } from "@/utils/product/productItemUtils"; import { useProductContext } from "@/views/products/product/ProductContext"; -import { AttachBranch } from "@autumn/shared"; export const OptionsResult = () => { const { org } = useOrg(); @@ -67,7 +66,7 @@ export const OptionsResult = () => {
{formatAmount({ - defaultCurrency: currency, + currency, amount: proratedAmount, maxFractionDigits: 2, })}{" "} @@ -79,7 +78,7 @@ export const OptionsResult = () => { {title} {formatAmount({ - defaultCurrency: currency, + currency, amount: amount, maxFractionDigits: 2, })} diff --git a/vite/src/views/customers/customer/product/components/attach-preview/UpdateQuantity.tsx b/vite/src/views/customers/customer/product/components/attach-preview/UpdateQuantity.tsx index dfc0674c3..0080014b1 100644 --- a/vite/src/views/customers/customer/product/components/attach-preview/UpdateQuantity.tsx +++ b/vite/src/views/customers/customer/product/components/attach-preview/UpdateQuantity.tsx @@ -1,11 +1,15 @@ +import { + AttachBranch, + formatAmount, + getAmountForQuantity, + type Price, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; import { PriceItem } from "@/components/pricing/attach-pricing-dialog"; import { Input } from "@/components/ui/input"; import { useOrg } from "@/hooks/common/useOrg"; import { notNullish } from "@/utils/genUtils"; -import { formatAmount } from "@/utils/product/productItemUtils"; import { useProductContext } from "@/views/products/product/ProductContext"; -import { AttachBranch, getAmountForQuantity, Price } from "@autumn/shared"; -import { Decimal } from "decimal.js"; export const UpdateQuantity = () => { const { org } = useOrg(); @@ -22,7 +26,7 @@ export const UpdateQuantity = () => { if (notNullish(option.price)) { return `x ${formatAmount({ amount: option.price, - defaultCurrency: currency, + currency, maxFractionDigits: 5, })} per `; } @@ -149,7 +153,7 @@ export const UpdateQuantity = () => { {formatAmount({ amount: getTotalPrice(), - defaultCurrency: currency, + currency, maxFractionDigits: 2, })} diff --git a/vite/src/views/customers/customer/product/multi-attach/MultiAttachDialog.tsx b/vite/src/views/customers/customer/product/multi-attach/MultiAttachDialog.tsx index 99c8dddae..28c057afb 100644 --- a/vite/src/views/customers/customer/product/multi-attach/MultiAttachDialog.tsx +++ b/vite/src/views/customers/customer/product/multi-attach/MultiAttachDialog.tsx @@ -1,4 +1,4 @@ -import type { ProductV2 } from "@autumn/shared"; +import { formatAmount, type ProductV2 } from "@autumn/shared"; import type { CheckoutResult } from "autumn-js"; import { ArrowUpRightFromSquare, Loader2, Plus, X } from "lucide-react"; import { useEffect, useState } from "react"; @@ -26,7 +26,6 @@ import { useEnv } from "@/utils/envUtils"; import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils"; import { getBackendErr } from "@/utils/genUtils"; import { getStripeInvoiceLink } from "@/utils/linkUtils"; -import { formatAmount } from "@/utils/product/productItemUtils"; import { useCusQuery } from "../../hooks/useCusQuery"; import { InvoiceCustomerButton } from "../components/InvoiceCustomerButton"; import { getCusProductMinQuantity } from "../utils/getCusProductMinQuantity"; @@ -311,7 +310,7 @@ export const MultiAttachDialog = ({

Total:

{formatAmount({ - defaultCurrency, + currency: defaultCurrency, amount: checkoutResult.total, maxFractionDigits: 2, })} @@ -329,7 +328,7 @@ export const MultiAttachDialog = ({

{formatAmount({ amount: checkoutResult.next_cycle?.total, - defaultCurrency, + currency: defaultCurrency, })}

diff --git a/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx b/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx index 595f81002..c9a1560bd 100644 --- a/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx +++ b/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx @@ -1,7 +1,7 @@ import { type FrontendProduct, formatAmount, - getIntervalString, + formatInterval, type Organization, type ProductV2, productV2ToBasePrice, @@ -63,7 +63,7 @@ export const BasePriceDisplay = ({ }); const secondaryText = basePrice.interval - ? `${getIntervalString({ interval: basePrice.interval, intervalCount: basePrice.interval_count })}` + ? `${formatInterval({ interval: basePrice.interval, intervalCount: basePrice.interval_count ?? undefined })}` : "one-off"; return ( diff --git a/vite/src/views/products/product/product-item/ProductItemRow.tsx b/vite/src/views/products/product/product-item/ProductItemRow.tsx index 8f3ff3804..64aca0567 100644 --- a/vite/src/views/products/product/product-item/ProductItemRow.tsx +++ b/vite/src/views/products/product/product-item/ProductItemRow.tsx @@ -1,29 +1,24 @@ // ProductItemRow.tsx import { - BillingInterval, - Feature, + type BillingInterval, + type Feature, FeatureType, + formatAmount, getFeatureName, Infinite, - ProductItem, + type ProductItem, ProductItemType, } from "@autumn/shared"; -import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; -import { - formatAmount, - getItemType, - intervalIsNone, -} from "@/utils/product/productItemUtils"; +import { DollarSign, Flag } from "lucide-react"; import { AdminHover } from "@/components/general/AdminHover"; -import { getFeature } from "@/utils/product/entitlementUtils"; import { Badge } from "@/components/ui/badge"; -import { DollarSign } from "lucide-react"; -import { Flag } from "lucide-react"; import { cn } from "@/lib/utils"; -import { isFeatureItem, isPriceItem } from "@/utils/product/getItemType"; -import { notNullish } from "@/utils/genUtils"; -import { useProductContext } from "../ProductContext"; +import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; import { formatIntervalText } from "@/utils/formatUtils/formatTextUtils"; +import { notNullish } from "@/utils/genUtils"; +import { getFeature } from "@/utils/product/entitlementUtils"; +import { isFeatureItem, isPriceItem } from "@/utils/product/getItemType"; +import { getItemType, intervalIsNone } from "@/utils/product/productItemUtils"; interface ProductItemRowProps { item: ProductItem; @@ -116,20 +111,20 @@ export const ProductItemRow = ({ if (item.price) { amountStr = formatAmount({ - defaultCurrency: org?.default_currency || "USD", + currency: org?.default_currency || "USD", amount: item.price, }); } else if (item.tiers && item.tiers.length == 1) { amountStr = formatAmount({ - defaultCurrency: org?.default_currency || "USD", + currency: org?.default_currency || "USD", amount: item.tiers![0].amount, }); } else { amountStr = `${formatAmount({ - defaultCurrency: org?.default_currency || "USD", + currency: org?.default_currency || "USD", amount: item.tiers![0].amount, })} - ${formatAmount({ - defaultCurrency: org?.default_currency || "USD", + currency: org?.default_currency || "USD", amount: item.tiers![item.tiers!.length - 1].amount, })}`; } @@ -166,9 +161,9 @@ export const ProductItemRow = ({ }; const getFixedPriceString = (item: ProductItem) => { - const currency = org?.default_currency || "USD"; + const currencyCode = org?.default_currency || "USD"; const formattedAmount = formatAmount({ - defaultCurrency: currency, + currency: currencyCode, amount: item.price!, });