diff --git a/bun.lock b/bun.lock index 8ec7fd63e..2a1c4aa98 100644 --- a/bun.lock +++ b/bun.lock @@ -199,6 +199,7 @@ "dependencies": { "@ai-sdk/anthropic": "^3.0.9", "@anthropic-ai/sdk": "^0.32.1", + "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", "@aws-sdk/client-sqs": "^3.958.0", "@axiomhq/pino": "^1.3.1", @@ -4572,6 +4573,10 @@ "@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], + "@autumn/openapi/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + + "@autumn/server/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@autumn/server/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], "@autumn/server/autumn-js": ["autumn-js@0.1.76", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.17", "better-call": "^1.0.12", "convex": "^1.25.4" }, "optionalPeers": ["better-auth", "better-call", "convex"] }, "sha512-WAJ+7NNPTli7swoeL5cgHuZCUiFsrjhM2Coc0UXzN9YCfTmaN8VeAOsfV+vtsyp5Pk37VJD8iW7Uyd5GgOn4IA=="], @@ -4580,6 +4585,8 @@ "@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="], + "@autumn/shared/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@autumn/vite/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], "@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], @@ -5796,6 +5803,10 @@ "@asyncapi/parser/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@autumn/openapi/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "@autumn/server/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "@autumn/server/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "@autumn/server/autumn-js/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -5820,6 +5831,8 @@ "@autumn/server/ink/widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + "@autumn/shared/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -6636,12 +6649,16 @@ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@autumn/openapi/@types/bun/bun-types/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@autumn/server/ink/@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "@autumn/server/ink/cli-truncate/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "@autumn/server/ink/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "@autumn/shared/@types/bun/bun-types/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], @@ -6944,8 +6961,12 @@ "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@autumn/openapi/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@autumn/server/ink/cli-truncate/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "@autumn/shared/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@4.1.3", "", { "dependencies": { "@smithy/protocol-http": "^4.1.8", "@smithy/querystring-builder": "^3.0.11", "@smithy/types": "^3.7.2", "@smithy/util-base64": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-6SxNltSncI8s689nvnzZQc/dPXcpHQ34KUj6gR/HBroytKOd/isMG3gJF/zBE1TBmTT18TXyzhg3O3SOOqGEhA=="], "@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from": ["@smithy/util-buffer-from@3.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA=="], diff --git a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx index 10284f481..a95ff31f0 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx @@ -1,9 +1,5 @@ import { FeatureType, TierBehavior } from "@autumn/shared"; -import { - DropSimpleIcon, - PencilSimpleIcon, - RulerIcon, -} from "@phosphor-icons/react"; +import { PencilSimpleIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { @@ -11,13 +7,6 @@ import { useProduct, useSheet, } from "@/components/v2/inline-custom-plan-editor/PlanEditorContext"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/v2/selects/Select"; import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { getFeature } from "@/utils/product/entitlementUtils"; @@ -25,10 +14,15 @@ import { isFeaturePriceItem } from "@/utils/product/getItemType"; import UpdateFeatureSheet from "@/views/products/features/components/UpdateFeatureSheet"; import UpdateCreditSystemSheet from "@/views/products/features/credit-systems/components/UpdateCreditSystemSheet"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; +import { + cleanTiersForMode, + type VolumePricingMode, +} from "../../utils/tierUtils"; import { AdvancedSettings } from "./AdvancedSettings"; import { BillingType } from "./BillingType"; import { IncludedUsage } from "./IncludedUsage"; import { PricedFeatureSettings } from "./PricedFeatureSettings"; +import { PriceSectionTitle } from "./PriceSectionTitle"; import { PriceTiers } from "./PriceTiers"; import { SheetFooterActions } from "./SheetFooterActions"; import { UsageReset } from "./UsageReset"; @@ -45,6 +39,44 @@ export function EditPlanFeatureSheet({ const hasItemChanges = useHasItemChanges(); const [editFeatureOpen, setEditFeatureOpen] = useState(false); + // Infer initial mode from tier data: if any tier has flat_amount > 0, default to flat + const [volumePricingMode, setVolumePricingMode] = useState( + () => { + const hasFlatAmount = item?.tiers?.some( + (t) => t.flat_amount != null && t.flat_amount > 0, + ); + return hasFlatAmount ? "flat" : "per_unit"; + }, + ); + + const isVolumeBased = item?.tier_behavior === TierBehavior.VolumeBased; + const isMultiTier = (item?.tiers?.length ?? 0) > 1; + const showVolumePricingToggle = isVolumeBased && isMultiTier; + + const handleTierBehaviorChange = (val: string) => { + const newBehavior = val as TierBehavior; + const newItem = { ...item, tier_behavior: newBehavior }; + + // When switching away from volume-based, reset mode and clear flat_amount + if (newBehavior !== TierBehavior.VolumeBased) { + setVolumePricingMode("per_unit"); + if (newItem.tiers) { + newItem.tiers = newItem.tiers.map((tier) => ({ + ...tier, + flat_amount: null, + })); + } + } + + setItem(newItem); + }; + + const handleBeforeCommit = () => { + if (!showVolumePricingToggle) return; + const cleaned = cleanTiersForMode({ item, mode: volumePricingMode }); + setItem(cleaned); + }; + const handleFeatureUpdateSuccess = async (oldId: string, newId: string) => { if (oldId !== newId && product.items) { // Wait for features to be refetched to avoid race condition @@ -121,53 +153,15 @@ export function EditPlanFeatureSheet({ 1 ? ( -
- Price - -
+ ) : ( "Price" ) @@ -175,7 +169,11 @@ export function EditPlanFeatureSheet({ className="space-y-3" >
- +
@@ -198,7 +196,10 @@ export function EditPlanFeatureSheet({ {/* Footer stays at bottom */} - + {/* Edit Feature Sheet */} {feature?.type === FeatureType.CreditSystem ? ( diff --git a/vite/src/views/products/plan/components/edit-plan-feature/PriceSectionTitle.tsx b/vite/src/views/products/plan/components/edit-plan-feature/PriceSectionTitle.tsx new file mode 100644 index 000000000..e3bb59121 --- /dev/null +++ b/vite/src/views/products/plan/components/edit-plan-feature/PriceSectionTitle.tsx @@ -0,0 +1,98 @@ +import { TierBehavior } from "@autumn/shared"; +import { + CoinsIcon, + DropSimpleIcon, + RulerIcon, + StackIcon, +} from "@phosphor-icons/react"; +import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/v2/selects/Select"; +import { cn } from "@/lib/utils"; +import type { VolumePricingMode } from "../../utils/tierUtils"; + +export function PriceSectionTitle({ + tierBehavior, + volumePricingMode, + showVolumePricingToggle, + onTierBehaviorChange, + onVolumePricingModeChange, +}: { + tierBehavior: TierBehavior; + volumePricingMode: VolumePricingMode; + showVolumePricingToggle: boolean; + onTierBehaviorChange: (val: string) => void; + onVolumePricingModeChange: (mode: VolumePricingMode) => void; +}) { + return ( +
+ Price +
+ {showVolumePricingToggle && ( +
+ } + iconOrientation="left" + variant="secondary" + size="sm" + checked={volumePricingMode === "per_unit"} + onCheckedChange={() => onVolumePricingModeChange("per_unit")} + className={cn( + "rounded-r-none", + volumePricingMode !== "per_unit" && "border-r-0", + )} + > + Per Unit + + } + iconOrientation="left" + variant="secondary" + size="sm" + checked={volumePricingMode === "flat"} + onCheckedChange={() => onVolumePricingModeChange("flat")} + className={cn( + "rounded-l-none", + volumePricingMode !== "flat" && "border-l-0", + )} + > + Flat Amount + +
+ )} + +
+
+ ); +} diff --git a/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx b/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx index 3852703ea..b6fb14136 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx @@ -15,7 +15,12 @@ import { } from "@/components/v2/inputs/InputGroup"; import { useOrg } from "@/hooks/common/useOrg"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; -import { addTier, removeTier, updateTier } from "../../utils/tierUtils"; +import { + addTier, + removeTier, + updateTier, + type VolumePricingMode, +} from "../../utils/tierUtils"; import { BillingUnits } from "./BillingUnits"; const getTierToDisplay = ({ @@ -82,7 +87,11 @@ const TierToInput = ({ index }: { index: number }) => { ); }; -export function PriceTiers() { +export function PriceTiers({ + volumePricingMode, +}: { + volumePricingMode?: VolumePricingMode; +}) { const { item, setItem } = useProductItemContext(); const { org } = useOrg(); const currency = org?.default_currency?.toUpperCase() ?? "USD"; @@ -119,7 +128,7 @@ export function PriceTiers() { const handleInputBlur = ( key: string, - field: "amount" | "to", + field: "amount" | "to" | "flat_amount", tierIndex?: number, ) => { const rawValue = editingValues[key] || ""; @@ -133,7 +142,7 @@ export function PriceTiers() { const handleInputChange = ( key: string, value: string, - field: "amount" | "to", + field: "amount" | "to" | "flat_amount", tierIndex?: number, ) => { setEditingValues((prev) => ({ ...prev, [key]: value })); @@ -208,7 +217,10 @@ export function PriceTiers() { return (
{tiers.map((tier: PriceTier, index: number) => { - const amountKey = `tier-${index}-amount`; + const isFlatMode = volumePricingMode === "flat"; + const amountField = isFlatMode ? "flat_amount" : "amount"; + const amountKey = `tier-${index}-${amountField}`; + const amountValue = isFlatMode ? (tier.flat_amount ?? 0) : tier.amount; return (
@@ -222,11 +234,16 @@ export function PriceTiers() { handleInputFocus(amountKey, tier.amount)} - onBlur={() => handleInputBlur(amountKey, "amount", index)} + value={getDisplayValue(amountKey, amountValue)} + onFocus={() => handleInputFocus(amountKey, amountValue)} + onBlur={() => handleInputBlur(amountKey, amountField, index)} onChange={(e) => - handleInputChange(amountKey, e.target.value, "amount", index) + handleInputChange( + amountKey, + e.target.value, + amountField, + index, + ) } inputMode="decimal" placeholder="0.00" @@ -236,7 +253,7 @@ export function PriceTiers() { - + {!isFlatMode && }
void; +}) { const { setItem, handleUpdateProductItem } = useProductItemContext(); const { initialItem } = useSheet(); @@ -15,6 +21,7 @@ export function SheetFooterActions({ hasChanges }: { hasChanges: boolean }) { }; const handleUpdateItem = async () => { + onBeforeCommit?.(); await handleUpdateProductItem(); }; diff --git a/vite/src/views/products/plan/utils/tierUtils.ts b/vite/src/views/products/plan/utils/tierUtils.ts index a7821ca91..1f38ff38f 100644 --- a/vite/src/views/products/plan/utils/tierUtils.ts +++ b/vite/src/views/products/plan/utils/tierUtils.ts @@ -85,7 +85,7 @@ export const updateTier = ({ item: ProductItem; setItem: (item: ProductItem) => void; index: number; - field: "to" | "amount"; + field: "to" | "amount" | "flat_amount"; value: string; }) => { if (!item.tiers) return; @@ -116,7 +116,7 @@ export const updateTier = ({ newTiers[index + 1].to = numValue; } } - } else if (field === "amount") { + } else if (field === "amount" || field === "flat_amount") { // Handle empty string or numeric values let numValue: number; if (value === "") { @@ -125,7 +125,29 @@ export const updateTier = ({ const parsed = parseFloat(value); numValue = Number.isNaN(parsed) ? 0 : parsed; } - newTiers[index] = { ...newTiers[index], amount: numValue }; + newTiers[index] = { ...newTiers[index], [field]: numValue }; } setItem({ ...item, tiers: newTiers }); }; + +export type VolumePricingMode = "flat" | "per_unit"; + +/** Cleans tier data based on the active pricing mode before committing. */ +export const cleanTiersForMode = ({ + item, + mode, +}: { + item: ProductItem; + mode: VolumePricingMode; +}): ProductItem => { + if (!item.tiers) return item; + + const cleanedTiers = item.tiers.map((tier) => { + if (mode === "flat") { + return { ...tier, amount: 0 }; + } + return { ...tier, flat_amount: null }; + }); + + return { ...item, tiers: cleanedTiers }; +}; diff --git a/vite/tests/views/products/plan/utils/tierUtils.test.ts b/vite/tests/views/products/plan/utils/tierUtils.test.ts new file mode 100644 index 000000000..fdab919c9 --- /dev/null +++ b/vite/tests/views/products/plan/utils/tierUtils.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "bun:test"; +import { Infinite, type ProductItem } from "@autumn/shared"; +import { + cleanTiersForMode, + updateTier, +} from "@/views/products/plan/utils/tierUtils"; + +const makeTier = ({ + to = 100, + amount = 5, + flat_amount = null, +}: { + to?: number | typeof Infinite; + amount?: number; + flat_amount?: number | null; +}) => ({ to, amount, flat_amount }); + +const makeItem = ({ + tiers, +}: { + tiers: ReturnType[] | null; +}): ProductItem => + ({ + feature_id: "feat_test", + tiers, + }) as ProductItem; + +describe("cleanTiersForMode", () => { + test("flat mode zeroes out amount and preserves flat_amount", () => { + const item = makeItem({ + tiers: [ + makeTier({ to: 100, amount: 1.5, flat_amount: 50 }), + makeTier({ to: Infinite, amount: 2, flat_amount: 100 }), + ], + }); + + const result = cleanTiersForMode({ item, mode: "flat" }); + + expect(result.tiers).toHaveLength(2); + expect(result.tiers![0].amount).toBe(0); + expect(result.tiers![0].flat_amount).toBe(50); + expect(result.tiers![1].amount).toBe(0); + expect(result.tiers![1].flat_amount).toBe(100); + }); + + test("per_unit mode nulls out flat_amount and preserves amount", () => { + const item = makeItem({ + tiers: [ + makeTier({ to: 100, amount: 1.5, flat_amount: 50 }), + makeTier({ to: Infinite, amount: 2, flat_amount: 100 }), + ], + }); + + const result = cleanTiersForMode({ item, mode: "per_unit" }); + + expect(result.tiers).toHaveLength(2); + expect(result.tiers![0].amount).toBe(1.5); + expect(result.tiers![0].flat_amount).toBeNull(); + expect(result.tiers![1].amount).toBe(2); + expect(result.tiers![1].flat_amount).toBeNull(); + }); + + test("returns item unchanged when tiers is null", () => { + const item = makeItem({ tiers: null }); + const result = cleanTiersForMode({ item, mode: "flat" }); + expect(result.tiers).toBeNull(); + }); + + test("flat mode handles tiers where flat_amount is null/undefined", () => { + const item = makeItem({ + tiers: [ + makeTier({ to: 100, amount: 3, flat_amount: null }), + makeTier({ to: Infinite, amount: 0, flat_amount: null }), + ], + }); + + const result = cleanTiersForMode({ item, mode: "flat" }); + + expect(result.tiers![0].amount).toBe(0); + expect(result.tiers![0].flat_amount).toBeNull(); + expect(result.tiers![1].amount).toBe(0); + }); + + test("per_unit mode handles tiers that already have no flat_amount", () => { + const item = makeItem({ + tiers: [makeTier({ to: 100, amount: 5, flat_amount: null })], + }); + + const result = cleanTiersForMode({ item, mode: "per_unit" }); + + expect(result.tiers![0].amount).toBe(5); + expect(result.tiers![0].flat_amount).toBeNull(); + }); + + test("does not mutate the original item", () => { + const item = makeItem({ + tiers: [makeTier({ to: 100, amount: 5, flat_amount: 50 })], + }); + + cleanTiersForMode({ item, mode: "flat" }); + + expect(item.tiers![0].amount).toBe(5); + expect(item.tiers![0].flat_amount).toBe(50); + }); +}); + +describe("updateTier — flat_amount field", () => { + test("sets flat_amount on the correct tier", () => { + let captured: ProductItem | null = null; + const item = makeItem({ + tiers: [ + makeTier({ to: 100, amount: 1 }), + makeTier({ to: Infinite, amount: 0 }), + ], + }); + + updateTier({ + item, + setItem: (i) => { + captured = i; + }, + index: 0, + field: "flat_amount", + value: "25.50", + }); + + expect(captured).not.toBeNull(); + expect(captured!.tiers![0].flat_amount).toBe(25.5); + expect(captured!.tiers![0].amount).toBe(1); + }); + + test("sets flat_amount to 0 for empty string", () => { + let captured: ProductItem | null = null; + const item = makeItem({ + tiers: [makeTier({ to: 100, amount: 1, flat_amount: 50 })], + }); + + updateTier({ + item, + setItem: (i) => { + captured = i; + }, + index: 0, + field: "flat_amount", + value: "", + }); + + expect(captured!.tiers![0].flat_amount).toBe(0); + }); + + test("sets flat_amount to 0 for non-numeric string", () => { + let captured: ProductItem | null = null; + const item = makeItem({ + tiers: [makeTier({ to: 100, amount: 1, flat_amount: 50 })], + }); + + updateTier({ + item, + setItem: (i) => { + captured = i; + }, + index: 0, + field: "flat_amount", + value: "abc", + }); + + expect(captured!.tiers![0].flat_amount).toBe(0); + }); + + test("does not affect other tiers", () => { + let captured: ProductItem | null = null; + const item = makeItem({ + tiers: [ + makeTier({ to: 100, amount: 1, flat_amount: 10 }), + makeTier({ to: Infinite, amount: 2, flat_amount: 20 }), + ], + }); + + updateTier({ + item, + setItem: (i) => { + captured = i; + }, + index: 1, + field: "flat_amount", + value: "99", + }); + + expect(captured!.tiers![0].flat_amount).toBe(10); + expect(captured!.tiers![1].flat_amount).toBe(99); + }); + + test("amount field still works as before", () => { + let captured: ProductItem | null = null; + const item = makeItem({ + tiers: [makeTier({ to: 100, amount: 1, flat_amount: 50 })], + }); + + updateTier({ + item, + setItem: (i) => { + captured = i; + }, + index: 0, + field: "amount", + value: "7.25", + }); + + expect(captured!.tiers![0].amount).toBe(7.25); + expect(captured!.tiers![0].flat_amount).toBe(50); + }); + + test("does nothing when tiers is null", () => { + let captured: ProductItem | null = null; + const item = makeItem({ tiers: null }); + + updateTier({ + item, + setItem: (i) => { + captured = i; + }, + index: 0, + field: "flat_amount", + value: "10", + }); + + expect(captured).toBeNull(); + }); +});