feat: add volume flat amount ui

This commit is contained in:
Charlie Lamb
2026-03-03 12:43:30 +00:00
parent 3ffa4eb44f
commit c62fc87a7c
7 changed files with 470 additions and 75 deletions

View File

@@ -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=="],

View File

@@ -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<VolumePricingMode>(
() => {
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({
<SheetSection
title={
item.tiers && item.tiers.length > 1 ? (
<div className="flex items-center justify-between w-full">
<span>Price</span>
<Select
value={item.tier_behavior ?? TierBehavior.Graduated}
onValueChange={(val) =>
setItem({
...item,
tier_behavior: val as TierBehavior,
})
}
>
<SelectTrigger className="w-40 h-6 text-xs" size="sm">
<SelectValue>
{item.tier_behavior === TierBehavior.VolumeBased ? (
<span className="flex items-center gap-2">
<DropSimpleIcon
className="size-3.5"
weight="regular"
/>
Volume-based
</span>
) : (
<span className="flex items-center gap-2">
<RulerIcon
className="size-3.5"
weight="regular"
/>
Graduated
</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={TierBehavior.Graduated}>
<RulerIcon className="size-4" weight="regular" />
Graduated
</SelectItem>
<SelectItem value={TierBehavior.VolumeBased}>
<DropSimpleIcon
className="size-4"
weight="regular"
/>
Volume-based
</SelectItem>
</SelectContent>
</Select>
</div>
<PriceSectionTitle
tierBehavior={
item.tier_behavior ?? TierBehavior.Graduated
}
volumePricingMode={volumePricingMode}
showVolumePricingToggle={showVolumePricingToggle}
onTierBehaviorChange={handleTierBehaviorChange}
onVolumePricingModeChange={setVolumePricingMode}
/>
) : (
"Price"
)
@@ -175,7 +169,11 @@ export function EditPlanFeatureSheet({
className="space-y-3"
>
<div>
<PriceTiers />
<PriceTiers
volumePricingMode={
showVolumePricingToggle ? volumePricingMode : undefined
}
/>
<UsageReset showBillingLabel={true} />
</div>
<PricedFeatureSettings />
@@ -198,7 +196,10 @@ export function EditPlanFeatureSheet({
</div>
{/* Footer stays at bottom */}
<SheetFooterActions hasChanges={hasChanges} />
<SheetFooterActions
hasChanges={hasChanges}
onBeforeCommit={handleBeforeCommit}
/>
{/* Edit Feature Sheet */}
{feature?.type === FeatureType.CreditSystem ? (

View File

@@ -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 (
<div className="flex items-center justify-between w-full">
<span>Price</span>
<div className="flex items-center gap-2">
{showVolumePricingToggle && (
<div className="flex items-center">
<IconCheckbox
icon={<CoinsIcon />}
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
</IconCheckbox>
<IconCheckbox
icon={<StackIcon />}
iconOrientation="left"
variant="secondary"
size="sm"
checked={volumePricingMode === "flat"}
onCheckedChange={() => onVolumePricingModeChange("flat")}
className={cn(
"rounded-l-none",
volumePricingMode !== "flat" && "border-l-0",
)}
>
Flat Amount
</IconCheckbox>
</div>
)}
<Select value={tierBehavior} onValueChange={onTierBehaviorChange}>
<SelectTrigger className="w-40 h-6 text-xs" size="sm">
<SelectValue>
{tierBehavior === TierBehavior.VolumeBased ? (
<span className="flex items-center gap-2">
<DropSimpleIcon className="size-3.5" weight="regular" />
Volume-based
</span>
) : (
<span className="flex items-center gap-2">
<RulerIcon className="size-3.5" weight="regular" />
Graduated
</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={TierBehavior.Graduated}>
<RulerIcon className="size-4" weight="regular" />
Graduated
</SelectItem>
<SelectItem value={TierBehavior.VolumeBased}>
<DropSimpleIcon className="size-4" weight="regular" />
Volume-based
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-2">
{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 (
<div key={index} className="flex gap-2 w-full items-center">
@@ -222,11 +234,16 @@ export function PriceTiers() {
<InputGroup className="min-w-0 w-26 shrink-0">
<InputGroupInput
value={getDisplayValue(amountKey, tier.amount)}
onFocus={() => 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() {
</InputGroupAddon>
</InputGroup>
<BillingUnits />
{!isFlatMode && <BillingUnits />}
<div className="flex items-center gap-1 shrink-0">
<IconButton

View File

@@ -4,7 +4,13 @@ import { useSheet } from "@/components/v2/inline-custom-plan-editor/PlanEditorCo
import { cn } from "@/lib/utils";
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
export function SheetFooterActions({ hasChanges }: { hasChanges: boolean }) {
export function SheetFooterActions({
hasChanges,
onBeforeCommit,
}: {
hasChanges: boolean;
onBeforeCommit?: () => void;
}) {
const { setItem, handleUpdateProductItem } = useProductItemContext();
const { initialItem } = useSheet();
@@ -15,6 +21,7 @@ export function SheetFooterActions({ hasChanges }: { hasChanges: boolean }) {
};
const handleUpdateItem = async () => {
onBeforeCommit?.();
await handleUpdateProductItem();
};

View File

@@ -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 };
};

View File

@@ -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<typeof makeTier>[] | 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();
});
});