Merge branch 'staging' into feat/stainless

This commit is contained in:
John Yeo
2025-10-02 12:39:17 +01:00
7 changed files with 180 additions and 145 deletions

View File

@@ -1,33 +1,36 @@
import chalk from "chalk";
import Stripe from "stripe";
import { APIVersion, AppEnv, Customer, Organization } from "@autumn/shared";
import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js";
import {
APIVersion,
type AppEnv,
type Customer,
type Organization,
} 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 { rewards } from "tests/global.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { timeout } from "tests/utils/genUtils.js";
import { createProducts } from "tests/utils/productUtils.js";
import {
advanceTestClock,
completeCheckoutForm,
getDiscount,
} from "tests/utils/stripeUtils.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { setupBefore } from "tests/before.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import {
addPrefixToProducts,
getBasePrice,
} from "tests/utils/testProductUtils/testProductUtils.js";
import { createProducts } from "tests/utils/productUtils.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
const testCase = "coupon1";
@@ -65,7 +68,7 @@ const simulateOneCycle = async ({
});
// Expected invoice total
let expectedTotal = await getExpectedInvoiceTotal({
const expectedTotal = await getExpectedInvoiceTotal({
usage: [{ featureId: TestFeature.Words, value: usage }],
customerId,
productId: pro.id,
@@ -117,7 +120,7 @@ describe(
`${testCase} - Testing invoice credits reward, apply to all product`,
),
() => {
let customerId = "coupon1";
const customerId = "coupon1";
let stripeCli: Stripe;
let customer: Customer;
let testClockId: string;
@@ -125,7 +128,7 @@ describe(
let org: Organization;
let env: AppEnv;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
const autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let couponAmount = rewards.rolloverAll.discount_config.discount_value;
let curUnix = new Date().getTime();

View File

@@ -1,43 +1,35 @@
import {
advanceClockForInvoice,
completeCheckoutForm,
getDiscount,
} from "tests/utils/stripeUtils.js";
import chalk from "chalk";
import Stripe from "stripe";
import { expect } from "chai";
import {
APIVersion,
AppEnv,
type AppEnv,
CouponDurationType,
CreateReward,
Organization,
type CreateReward,
type Organization,
RewardType,
} from "@autumn/shared";
import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js";
import { getPriceForOverage } from "@/internal/products/prices/priceUtils.js";
import { timeout } from "tests/utils/genUtils.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { expect } from "chai";
import chalk from "chalk";
import { addHours, addMonths } from "date-fns";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { setupBefore } from "tests/before.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { timeout } from "tests/utils/genUtils.js";
import { createProducts, createReward } from "tests/utils/productUtils.js";
import { completeCheckoutForm, getDiscount } from "tests/utils/stripeUtils.js";
import {
addPrefixToProducts,
getBasePrice,
} from "tests/utils/testProductUtils/testProductUtils.js";
import { createProducts, createReward } from "tests/utils/productUtils.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { Decimal } from "decimal.js";
import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
import { addHours, addMonths } from "date-fns";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
const pro = constructProduct({
type: "pro",
@@ -66,11 +58,11 @@ describe(
chalk.yellow(`${testCase} - Testing one-off rollover, apply to usage only`),
() => {
let logger: any;
let customerId = testCase;
const customerId = testCase;
let stripeCli: Stripe;
let testClockId: string;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
const autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let org: Organization;
let env: AppEnv;
let db: DrizzleCli;
@@ -163,7 +155,7 @@ describe(
value: usage,
});
let usageTotal = await getExpectedInvoiceTotal({
const usageTotal = await getExpectedInvoiceTotal({
org,
env,
db,
@@ -174,7 +166,7 @@ describe(
onlyIncludeUsage: true,
});
let basePrice = getBasePrice({ product: pro });
const basePrice = getBasePrice({ product: pro });
couponAmount = couponAmount - usageTotal;

View File

@@ -1,21 +1,23 @@
import { useEffect, useState } from "react";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Input } from "@/components/ui/input";
import { slugify } from "@/utils/formatUtils/formatTextUtils";
import { cn } from "@/lib/utils";
import { Clock, InfoIcon, PlusIcon, XIcon, Zap } from "lucide-react";
import { Expression, FeatureUsageType, MeteredConfig } from "@autumn/shared";
import { FeatureType } from "@autumn/shared";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
type Expression,
FeatureType,
FeatureUsageType,
type MeteredConfig,
} from "@autumn/shared";
import { PlusIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { notNullish, nullish } from "@/utils/genUtils";
import { cn } from "@/lib/utils";
import { slugify } from "@/utils/formatUtils/formatTextUtils";
import { nullish } from "@/utils/genUtils";
import { SelectFeatureType } from "./SelectFeatureType";
import { SelectFeatureUsageType } from "./SelectFeatureUsageType";
@@ -70,6 +72,14 @@ export function FeatureConfig({
);
const [idChanged, setIdChanged] = useState(!!feature.id);
// Helper function to update meteredConfig and sync to parent
const updateMeteredConfig = (newConfig: MeteredConfig) => {
setMeteredConfig(newConfig);
if (feature.type === FeatureType.Metered) {
setFeature({ ...feature, config: newConfig });
}
};
const showNameAndId = () => {
if (nullish(feature.type)) {
return false;
@@ -92,7 +102,7 @@ export function FeatureConfig({
when purchasing a product
</div>
<SelectFeatureType feature={feature} setFeature={setFeature} />
{feature.type == FeatureType.Metered && (
{feature.type === FeatureType.Metered && (
<SelectFeatureUsageType feature={feature} setFeature={setFeature} />
)}
@@ -139,7 +149,7 @@ export function FeatureConfig({
<FilterInput
config={meteredConfig}
setConfig={setMeteredConfig}
setConfig={updateMeteredConfig}
eventNameInput={eventNameInput}
setEventNameInput={setEventNameInput}
setEventNameChanged={setEventNameChanged}
@@ -150,6 +160,7 @@ export function FeatureConfig({
<a
href="https://docs.useautumn.com/features/tracking-usage#using-event-names"
target="_blank"
rel="noreferrer"
className="text-primary underline"
>
here.
@@ -219,7 +230,7 @@ export const FilterInput = ({
const enterClicked = () => {
let newFilter: Expression;
if (filter.value.length == 0) {
if (filter.value.length === 0) {
newFilter = {
property: "",
operator: "",
@@ -244,12 +255,6 @@ export const FilterInput = ({
enableOnFormTags: ["input"],
enabled: inputFocused,
});
// useHotkeys(["meta+enter"], enterClicked, {
// enableOnContentEditable: true,
// enabled: inputFocused,
// });
return (
<div
className={cn(
@@ -266,6 +271,7 @@ export const FilterInput = ({
>
{value}
<button
type="button"
className="text-zinc-500"
onClick={() => onRemoveClicked(index)}
>

View File

@@ -36,9 +36,20 @@ function CreateReward() {
(async () => {
if (!reward?.id && !reward?.name) {
toast.error("ID and name are required");
setIsLoading(false);
return;
}
// Validate product selection for discount rewards
if (reward.discount_config) {
const { apply_to_all, price_ids } = reward.discount_config;
if (!apply_to_all && (!price_ids || price_ids.length === 0)) {
toast.error("Please select price(s) to apply this reward to");
setIsLoading(false);
return;
}
}
try {
await RewardService.createReward({
axiosInstance,

View File

@@ -124,19 +124,6 @@ export const DiscountConfig = ({
</div>
</div>
{/* {config.duration_type !== CouponDurationType.OneOff &&
reward.type === RewardType.FixedDiscount && (
<div className="w-full ml-1 flex items-center gap-2">
<Checkbox
checked={config.should_rollover}
onCheckedChange={(checked) =>
setConfig("should_rollover", checked === true)
}
/>
<p className="text-sm text-t3">Rollover credits to next invoice</p>
</div>
)} */}
<div className="">
{/* <p className="text-t2 mb-2 text-t3">Products</p> */}
<FieldLabel>Products</FieldLabel>
@@ -179,6 +166,24 @@ const ProductPriceSelector = ({
setConfig("price_ids", newPriceIds);
};
// Handle toggling "Apply to all products"
const handleApplyToAllToggle = () => {
const newApplyToAll = !config.apply_to_all;
// If enabling "Apply to all", clear the price_ids
if (newApplyToAll) {
setReward({
...reward,
discount_config: {
...config,
apply_to_all: newApplyToAll,
price_ids: [],
},
});
} else {
setConfig("apply_to_all", newApplyToAll);
}
};
if (!products || products.length === 0) {
return <p className="text-sm text-t3">No products available</p>;
}
@@ -192,48 +197,54 @@ const ProductPriceSelector = ({
aria-expanded={open}
className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50"
>
{config.apply_to_all
? "All Products"
: config.price_ids?.length === 0
? "Select Products"
: config.price_ids?.map((priceId) => {
const item = products
.find((p: any) =>
p.items.find((i: any) => i.price_id === priceId),
)
?.items.find((i: any) => i.price_id === priceId);
{config.apply_to_all ? (
<span className="text-t2">All Products</span>
) : config.price_ids?.length === 0 ? (
<span className="text-t3">Select products or apply to all</span>
) : (
config.price_ids?.map((priceId) => {
const item = products
.find((p: any) =>
p.items.find((i: any) => i.price_id === priceId),
)
?.items.find((i: any) => i.price_id === priceId);
const text = item
? formatProductItemText({
item,
org,
features,
})
: "Unknown Price";
return (
<div
key={priceId}
className="py-1 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full flex items-center gap-2 h-fit max-w-[200px] min-w-0"
>
<p className="truncate flex-1 min-w-0">{text}</p>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handlePriceToggle(priceId);
}}
className="bg-transparent hover:bg-transparent p-0 w-5 h-5"
>
<X size={12} className="text-t3" />
</Button>
</div>
);
})}
const text = item
? formatProductItemText({
item,
org,
features,
})
: "Unknown Price";
return (
<div
key={priceId}
className="py-1 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full flex items-center gap-2 h-fit max-w-[200px] min-w-0"
>
<p className="truncate flex-1 min-w-0">{text}</p>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handlePriceToggle(priceId);
}}
className="bg-transparent hover:bg-transparent p-0 w-5 h-5"
>
<X size={12} className="text-t3" />
</Button>
</div>
);
})
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 absolute right-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search prices..." className="h-9" />
<CommandList className="max-h-[300px] overflow-y-auto">
@@ -241,9 +252,7 @@ const ProductPriceSelector = ({
<CommandEmpty>No prices found.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
setConfig("apply_to_all", !config.apply_to_all);
}}
onSelect={handleApplyToAllToggle}
className="cursor-pointer"
>
<p>Apply to all products</p>
@@ -253,19 +262,27 @@ const ProductPriceSelector = ({
</CommandItem>
</CommandGroup>
{!config.apply_to_all &&
products.map((product: any) => (
<CommandGroup key={product.id} heading={product.name}>
{product.items.length > 0 ? (
product.items
?.filter((item: ProductItem) => {
return !isFeatureItem(item);
})
.map((item: any) => (
products
.filter((product: any) => {
// Filter out products that have no non-feature items
const nonFeatureItems = product.items?.filter(
(item: ProductItem) => !isFeatureItem(item),
);
return nonFeatureItems && nonFeatureItems.length > 0;
})
.map((product: any) => {
const nonFeatureItems = product.items.filter(
(item: ProductItem) => !isFeatureItem(item),
);
return (
<CommandGroup key={product.id} heading={product.name}>
{nonFeatureItems.map((item: any) => (
<CommandItem
key={item.price_id}
value={item.price_id}
onSelect={() => handlePriceToggle(item.price_id)}
className="cursor-pointer overflow-x-hidden max-w-[380px]"
className="cursor-pointer overflow-x-hidden"
>
<span className="truncate overflow-x-hidden">
{formatProductItemText({
@@ -279,14 +296,10 @@ const ProductPriceSelector = ({
<Check size={12} className="text-t3" />
)}
</CommandItem>
))
) : (
<CommandItem disabled>
<p className="text-sm text-t3">No prices available</p>
</CommandItem>
)}
</CommandGroup>
))}
))}
</CommandGroup>
);
})}
</ScrollArea>
</CommandList>
</Command>

View File

@@ -72,6 +72,16 @@ function UpdateReward({
const handleUpdate = async () => {
setUpdateLoading(true);
try {
// Validate product selection for discount rewards
if (selectedReward.discount_config) {
const { apply_to_all, price_ids } = selectedReward.discount_config;
if (!apply_to_all && (!price_ids || price_ids.length === 0)) {
toast.error("Please select price(s) to apply this reward to");
setUpdateLoading(false);
return;
}
}
// Check migration status and show warning if needed
if (products) {
const migrationResult = checkRewardMigration(selectedReward, products);

View File

@@ -11,7 +11,7 @@ export const defaultDiscountConfig: DiscountConfig = {
duration_type: CouponDurationType.Months,
duration_value: 0,
should_rollover: true,
apply_to_all: true,
apply_to_all: false,
price_ids: [],
};