Merge pull request #216 from SirTenzin/feat/reward-migrations

fix: 🐛 reward price_ids migrations
This commit is contained in:
John Yeo
2025-09-22 12:43:36 +01:00
committed by GitHub
22 changed files with 1673 additions and 1200 deletions

View File

@@ -13,6 +13,7 @@ import { SupabaseClient } from "@supabase/supabase-js";
import Stripe from "stripe";
import { billingIntervalToStripe } from "../stripePriceUtils.js";
import {
formatPrice,
getBillingType,
getPriceEntitlement,
} from "@/internal/products/prices/priceUtils.js";

View File

@@ -0,0 +1,268 @@
import {
type AppEnv,
type FixedPriceConfig,
type FullProduct,
type Price,
type UsagePriceConfig,
DiscountConfig,
PriceType,
RewardType,
getBillingType,
isFixedPrice,
isUsagePrice,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { logger as loggerType } from "@/external/logtail/logtailUtils.js";
import type { JobName } from "@/queue/JobName.js";
import type { Payloads } from "@/queue/queueUtils.js";
import { RewardService } from "../rewards/RewardService.js";
import { tiersAreSame } from "../products/prices/priceInitUtils.js";
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
import { PriceService } from "../products/prices/PriceService.js";
import { OrgService } from "../orgs/OrgService.js";
import { formatPrice } from "../products/prices/priceUtils.js";
import { ProductService } from "../products/ProductService.js";
// Helper function to check if tier structures match
const tiersMatch = (oldTiers: any[], newTiers: any[]): boolean => {
if (oldTiers.length !== newTiers.length) return false;
return oldTiers.every((oldTier, index) => {
const newTier = newTiers[index];
return oldTier.to === newTier.to && oldTier.amount === newTier.amount;
});
};
// Match fixed prices by amount
const findMatchingFixedPrice = (
oldPrice: Price,
candidates: Price[]
): Price | null => {
const oldConfig = oldPrice.config as FixedPriceConfig;
const possibleCandidate = candidates.find((candidate) => {
const newConfig = candidate.config as FixedPriceConfig;
return newConfig.amount === oldConfig.amount;
});
return possibleCandidate || candidates?.[0];
};
// Match usage prices by feature and billing characteristics
const findMatchingUsagePrice = (
oldPrice: Price,
candidates: Price[]
): Price | null => {
const oldConfig = oldPrice.config as UsagePriceConfig;
return (
candidates.find((candidate) => {
const newConfig = candidate.config as UsagePriceConfig;
// Match by feature
if (newConfig.internal_feature_id !== oldConfig.internal_feature_id)
return false;
// Match by billing behavior
let newBillingType = getBillingType(newConfig);
let oldBillingType = getBillingType(oldConfig);
if (newBillingType !== oldBillingType) return false;
// Optionally match by tier structure
// if (!tiersMatch(oldConfig.usage_tiers, newConfig.usage_tiers))
if (!tiersAreSame(oldConfig.usage_tiers, newConfig.usage_tiers))
return false;
return true;
}) || null
);
};
// Main matching function with type-specific logic
const findBestMatch = (oldPrice: Price, newPrices: Price[]): Price | null => {
// First, filter by basic characteristics
const candidates = newPrices.filter((newPrice) => {
if (newPrice.id === oldPrice.id) return true;
const oldConfig = oldPrice.config as UsagePriceConfig;
const newConfig = newPrice.config as UsagePriceConfig;
return (
getBillingType(newPrice.config) === getBillingType(oldPrice.config) &&
newPrice.config.interval === oldPrice.config.interval &&
newPrice.config.interval_count === oldPrice.config.interval_count &&
(oldConfig.type == PriceType.Usage
? oldConfig.internal_feature_id === newConfig.internal_feature_id
: true)
);
});
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
// If multiple candidates, use type-specific matching
if (isFixedPrice({ price: oldPrice })) {
return findMatchingFixedPrice(oldPrice, candidates);
} else if (isUsagePrice({ price: oldPrice })) {
return findMatchingUsagePrice(oldPrice, candidates);
}
// Fallback to first candidate
return candidates[0];
};
export async function runRewardMigrationTask({
db,
payload,
logger,
}: {
db: DrizzleCli;
payload: Payloads[JobName.RewardMigration];
logger: ReturnType<typeof loggerType.child>;
}) {
try {
const {
oldPrices,
productId,
// newPrices,
orgId,
env,
}: {
oldPrices: Price[];
// newPrices: Price[];
productId: string;
orgId: string;
env: AppEnv;
} = payload;
const fullProduct = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId,
env,
});
const newPrices = fullProduct.prices;
// Get organization for Stripe operations
const org = await OrgService.get({
db,
orgId,
});
const rewards = await RewardService.list({
db,
orgId,
env,
inTypes: [
RewardType.PercentageDiscount,
RewardType.FixedDiscount,
RewardType.InvoiceCredits,
],
});
const filteredRewards = rewards.filter(
(x) =>
x.org_id === orgId &&
x.env === env &&
x.type !== RewardType.FreeProduct &&
x.discount_config &&
x.discount_config.price_ids?.some((p) =>
oldPrices.map((p) => p.id).includes(p)
)
);
let shouldUpdateReward = false;
for (const reward of filteredRewards) {
const newPriceIds: string[] = [];
const unmatchedPrices: string[] = [];
if (reward.discount_config?.price_ids) {
for (const priceId of reward.discount_config.price_ids) {
const oldPrice = oldPrices.find((p) => p.id === priceId);
// From other product
if (!oldPrice) {
newPriceIds.push(priceId);
continue;
}
const matchingNewPrice = findBestMatch(oldPrice, newPrices);
if (matchingNewPrice) {
newPriceIds.push(matchingNewPrice.id);
const shouldUpdate =
matchingNewPrice.config.stripe_price_id !==
oldPrice.config.stripe_price_id ||
matchingNewPrice.config.stripe_product_id !==
oldPrice.config.stripe_product_id;
if (shouldUpdate) {
shouldUpdateReward = true;
}
} else {
unmatchedPrices.push(oldPrice.id);
}
}
}
// Update the reward with new price IDs
if (shouldUpdateReward) {
try {
// Update Stripe coupon and reward if price IDs have changed
console.log(
`Updating ${reward.id}, updating reward and Stripe coupon...`
);
// Update the reward in the database
const updatedReward = await RewardService.update({
db,
internalId: reward.internal_id!,
env,
orgId,
update: {
discount_config: {
...(reward.discount_config as DiscountConfig),
price_ids: newPriceIds,
},
},
});
// Get the price objects for the new price IDs
const prices = await PriceService.getInIds({
db,
ids: newPriceIds,
});
// Recreate the Stripe coupon with new product restrictions
await createStripeCoupon({
reward: updatedReward,
org,
env,
prices,
logger,
});
console.log(
`Successfully updated Stripe coupon for reward ${reward.id} with new product restrictions`
);
} catch (error) {
console.error(`Failed to update reward ${reward.id}:`, error);
}
}
if (unmatchedPrices.length > 0) {
console.warn(
`Unmatched prices for reward ${reward.id}:`,
unmatchedPrices
);
}
}
} catch (error) {
console.error("Error running reward migration task", { error });
throw error;
}
}

View File

@@ -14,6 +14,8 @@ import {
Price,
FullProduct,
FullEntitlement,
Rollover,
RolloverConfig,
} from "@autumn/shared";
import { addDays } from "date-fns";
@@ -76,6 +78,24 @@ export const addTrialToNextResetAt = (
return addDays(new Date(nextResetAt), freeTrial.length).getTime();
};
export const rolloversAreSame = ({
rollover1,
rollover2,
}: {
rollover1?: RolloverConfig | null;
rollover2?: RolloverConfig | null;
}) => {
if (!rollover1 && !rollover2) return true;
if (!rollover1 && rollover2) return false;
if (rollover1 && !rollover2) return false;
return (
rollover1!.max == rollover2!.max &&
rollover1!.duration == rollover2!.duration &&
rollover1!.length == rollover2!.length
);
};
export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => {
// 1. Check if they have same internal_feature_id
if (ent1.internal_feature_id !== ent2.internal_feature_id) {
@@ -121,23 +141,25 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => {
message: `Usage limit different: ${ent1.usage_limit} !== ${ent2.usage_limit}`,
},
rollover: {
condition:
JSON.stringify(ent1.rollover) !== JSON.stringify(ent2.rollover),
condition: !rolloversAreSame({
rollover1: ent1.rollover,
rollover2: ent2.rollover,
}),
message: `Rollover different: ${ent1.rollover} !== ${ent2.rollover}`,
},
};
let entsAreDiff = Object.values(diffs).some((d) => d.condition);
// if (entsAreDiff) {
// console.log("Entitlements different");
// console.log(
// "Differences:",
// Object.values(diffs)
// .filter((d) => d.condition)
// .map((d) => d.message),
// );
// }
if (entsAreDiff) {
console.log("Entitlements different");
console.log(
"Differences:",
Object.values(diffs)
.filter((d) => d.condition)
.map((d) => d.message)
);
}
return !entsAreDiff;
};

View File

@@ -1,28 +1,28 @@
import RecaseError from "@/utils/errorUtils.js";
import { ErrCode, FullProduct, UpdateProductSchema } from "@autumn/shared";
import { ProductService } from "../../ProductService.js";
import { notNullish } from "@/utils/genUtils.js";
import { ErrCode, type FullProduct, UpdateProductSchema } from "@autumn/shared";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { handleVersionProductV2 } from "../handleVersionProduct.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { handleUpdateProductDetails } from "./updateProductDetails.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { getEntsWithFeature } from "../../entitlements/entitlementUtils.js";
import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js";
import { ProductService } from "../../ProductService.js";
import { productsAreSame } from "../../productUtils/compareProductUtils.js";
import { initProductInStripe } from "../../productUtils.js";
import { mapToProductItems } from "../../productV2Utils.js";
import {
disableCurrentDefault,
handleCreateProduct,
} from "../handleCreateProduct.js";
import { mapToProductItems } from "../../productV2Utils.js";
import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js";
import { handleVersionProductV2 } from "../handleVersionProduct.js";
import { handleUpdateProductDetails } from "./updateProductDetails.js";
import { formatPrice } from "../../prices/priceUtils.js";
export const handleUpdateProductV2 = async (req: any, res: any) =>
routeHandler({
@@ -34,7 +34,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
const { version, upsert, disable_version } = req.query;
const { orgId, env, logger, db } = req;
const [features, org, fullProduct, rewardPrograms, defaultProds] =
const [features, org, fullProduct, rewardPrograms, _defaultProds] =
await Promise.all([
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
@@ -44,7 +44,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
orgId,
env,
version: version ? parseInt(version) : undefined,
allowNotFound: upsert == "true",
allowNotFound: upsert === "true",
}),
RewardProgramService.getByProductId({
db,
@@ -60,7 +60,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
]);
if (!fullProduct) {
if (upsert == "true") {
if (upsert === "true") {
await handleCreateProduct(req, res);
return;
}
@@ -78,7 +78,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
internalProductId: fullProduct.internal_id,
});
let cusProductExists = cusProductsCurVersion.length > 0;
const cusProductExists = cusProductsCurVersion.length > 0;
// console.log("Updating product", {
// id: fullProduct.id,
@@ -111,15 +111,14 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
logger,
});
let itemsExist = notNullish(req.body.items);
const itemsExist = notNullish(req.body.items);
if (cusProductExists && itemsExist) {
if (disable_version == "true") {
if (disable_version === "true") {
throw new RecaseError({
message: "Cannot auto save product as there are existing customers",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
return;
}
const { itemsSame, freeTrialsSame } = productsAreSame({
@@ -154,7 +153,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
});
}
const { prices, entitlements } = await handleNewProductItems({
await handleNewProductItems({
db,
curPrices: fullProduct.prices,
curEnts: fullProduct.entitlements,
@@ -165,9 +164,17 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
isCustom: false,
});
// New full product
const newFullProduct = await ProductService.getFull({
db,
idOrInternalId: fullProduct.id,
orgId,
env,
});
if (free_trial !== undefined) {
await validateOneOffTrial({
prices,
prices: newFullProduct.prices,
freeTrial: free_trial,
});
@@ -181,13 +188,10 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
});
}
// New full product
await initProductInStripe({
db,
product: {
...fullProduct,
prices,
entitlements,
} as FullProduct,
product: newFullProduct,
org,
env,
logger,
@@ -197,14 +201,19 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: {
...fullProduct,
prices: prices.length > 0 ? prices : fullProduct.prices,
entitlements,
},
curProduct: newFullProduct,
},
});
await addTaskToQueue({
jobName: JobName.RewardMigration,
payload: {
oldPrices: fullProduct.prices,
productId: fullProduct.id,
orgId: org.id,
env,
},
});
res.status(200).send({ message: "Product updated" });
return;
},

View File

@@ -1,25 +1,24 @@
import {
type AppEnv,
CreateProductSchema,
type FreeTrial,
type FullProduct,
type Organization,
type ProductItem,
} from "@autumn/shared";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js";
import {
constructProduct,
initProductInStripe,
} from "@/internal/products/productUtils.js";
import {
AppEnv,
CreateProductSchema,
FreeTrial,
Organization,
ProductItem,
} from "@autumn/shared";
import { FullProduct } from "@autumn/shared";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { getEntsWithFeature } from "../entitlements/entitlementUtils.js";
export const handleVersionProductV2 = async ({
@@ -41,13 +40,13 @@ export const handleVersionProductV2 = async ({
}) => {
const { db } = req;
let curVersion = latestProduct.version;
let newVersion = curVersion + 1;
const curVersion = latestProduct.version;
const newVersion = curVersion + 1;
let features = await FeatureService.getFromReq(req);
const features = await FeatureService.getFromReq(req);
console.log(
`Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}`
`Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}`,
);
const newProduct = constructProduct({
@@ -138,5 +137,20 @@ export const handleVersionProductV2 = async ({
logger: console,
});
await addTaskToQueue({
jobName: JobName.RewardMigration,
payload: {
oldPrices: latestProduct.prices,
newPrices: customPrices,
product: {
...newProduct,
prices: customPrices,
entitlements: getEntsWithFeature({ ents: customEnts, features }),
},
orgId: org.id,
env,
},
});
res.status(200).send(newProduct);
};

View File

@@ -14,13 +14,13 @@ import { RewardProgramService } from "../rewards/RewardProgramService.js";
import { mapToProductV2 } from "./productV2Utils.js";
import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js";
import RecaseError, {
handleFrontendReqError,
handleRequestError,
} from "@/utils/errorUtils.js";
import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
import { createOrgResponse } from "../orgs/orgUtils.js";
import { sortFullProducts } from "./productUtils/sortProductUtils.js";
import {
sortFullProducts,
sortProductsByPrice,
} from "./productUtils/sortProductUtils.js";
import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js";
export const productRouter: Router = Router({ mergeParams: true });
@@ -35,6 +35,8 @@ productRouter.get("/products", async (req: any, res) => {
env: req.env,
});
sortFullProducts({ products });
const groupToDefaults = getGroupToDefaults({
defaultProds: products,
});

View File

@@ -13,6 +13,7 @@ import { validateProductItems } from "../validateProductItems.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { isFeatureItem } from "./getItemType.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { formatPrice } from "../../prices/priceUtils.js";
const updateDbPricesAndEnts = async ({
db,

View File

@@ -1,3 +1,4 @@
import RecaseError from "@/utils/errorUtils.js";
import {
AllowanceType,
BillingInterval,
@@ -19,13 +20,12 @@ import {
OnIncrease,
OnDecrease,
FeatureUsageType,
features,
} from "@autumn/shared";
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
import { pricesAreSame } from "@/internal/products/prices/priceInitUtils.js";
import { entsAreSame } from "../../entitlements/entitlementUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import {
isFeatureItem,
isFeaturePriceItem,

View File

@@ -1,4 +1,10 @@
import { type AppEnv, ErrCode, type Reward, rewards } from "@autumn/shared";
import {
type AppEnv,
ErrCode,
type Reward,
rewards,
RewardType,
} from "@autumn/shared";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js";
@@ -19,10 +25,10 @@ export class RewardService {
where: and(
or(
eq(rewards.id, idOrInternalId),
eq(rewards.internal_id, idOrInternalId),
eq(rewards.internal_id, idOrInternalId)
),
eq(rewards.org_id, orgId),
eq(rewards.env, env),
eq(rewards.env, env)
),
});
@@ -54,9 +60,9 @@ export class RewardService {
(code) => sql`EXISTS (
SELECT 1 FROM unnest("promo_codes") AS elem
WHERE elem->>'code' = ${code}
)`,
),
),
)`
)
)
),
});
@@ -78,13 +84,19 @@ export class RewardService {
db,
orgId,
env,
inTypes,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
inTypes?: RewardType[];
}) {
const results = await db.query.rewards.findMany({
where: and(eq(rewards.org_id, orgId), eq(rewards.env, env)),
where: and(
eq(rewards.org_id, orgId),
eq(rewards.env, env),
inTypes ? inArray(rewards.type, inTypes) : undefined
),
orderBy: [desc(rewards.internal_id)],
});
@@ -108,8 +120,8 @@ export class RewardService {
and(
eq(rewards.internal_id, internalId),
eq(rewards.env, env),
eq(rewards.org_id, orgId),
),
eq(rewards.org_id, orgId)
)
);
}
@@ -133,8 +145,8 @@ export class RewardService {
and(
eq(rewards.internal_id, internalId),
eq(rewards.env, env),
eq(rewards.org_id, orgId),
),
eq(rewards.org_id, orgId)
)
)
.returning();

View File

@@ -3,6 +3,7 @@ export enum JobName {
UpdateUsage = "update-usage",
Migration = "migration",
RewardMigration = "reward-migration",
TriggerCheckoutReward = "trigger-checkout-reward",
GenerateFeatureDisplay = "generate-feature-display",

View File

@@ -1,21 +1,35 @@
import type { AppEnv, FullProduct, Price } from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js";
import { JobName } from "./JobName.js";
import { QueueManager } from "./QueueManager.js";
export const addTaskToQueue = async ({
export interface Payloads {
[JobName.RewardMigration]: {
oldPrices: Price[];
productId: string;
// newPrices: Price[];
// product: FullProduct;
orgId: string;
env: AppEnv;
};
[key: string]: any;
}
export const addTaskToQueue = async <T extends keyof Payloads>({
jobName,
payload,
}: {
jobName: string;
payload: any;
jobName: T;
payload: Payloads[T];
}) => {
try {
const queue = await QueueManager.getQueue({ useBackup: false });
await queue.add(jobName, payload);
await queue.add(jobName as string, payload);
} catch (error: any) {
try {
console.log(`Adding ${jobName} to backup queue`);
const backupQueue = await QueueManager.getQueue({ useBackup: true });
await backupQueue.add(jobName, payload);
await backupQueue.add(jobName as string, payload);
} catch (error: any) {
throw new RecaseError({
message: `Failed to add ${jobName} to queue (backup)`,

View File

@@ -14,6 +14,7 @@ import { generateId } from "@/utils/genUtils.js";
import { JobName } from "./JobName.js";
import { acquireLock, getRedisConnection, releaseLock } from "./lockUtils.js";
import { QueueManager } from "./QueueManager.js";
import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigrationTask.js";
const NUM_WORKERS = 10;
@@ -22,7 +23,7 @@ const actionHandlers = [
JobName.HandleCustomerCreated,
];
const { db, client } = initDrizzle({ maxConnections: 10 });
const { db } = initDrizzle({ maxConnections: 10 });
const initWorker = ({
id,
@@ -87,6 +88,14 @@ const initWorker = ({
});
return;
}
if (job.name === JobName.RewardMigration) {
await runRewardMigrationTask({
db,
payload: job.data,
logger: logtail,
});
}
} catch (error: any) {
logtail.error(`Failed to process bullmq job: ${job.name}`, {
jobName: job.name,
@@ -110,26 +119,19 @@ const initWorker = ({
await queue.add(job.name, job.data, {
delay: 1000,
});
logger.info(
"Lock not acquired for checkout reward, adding task to queue",
);
return;
}
try {
logger.info("Running checkout reward");
await runTriggerCheckoutReward({
db,
payload: job.data,
logger: logtail,
});
logger.info("Checkout reward triggered");
} catch (error) {
logger.error("Error processing job:", error);
console.error("Error processing job:", error);
} finally {
logger.info("Releasing lock for checkout reward");
await releaseLock({ lockKey, useBackup });
logger.info("Lock released for checkout reward");
}
return;
@@ -185,7 +187,7 @@ const initWorker = ({
},
drainDelay: 1000,
maxStalledCount: 0,
},
}
);
worker.on("ready", () => {
@@ -203,7 +205,7 @@ const initWorker = ({
}
});
worker.on("failed", (job, error) => {
worker.on("failed", (_, error) => {
console.log("WORKER FAILED:", error.message);
});
};
@@ -222,7 +224,7 @@ export const initWorkers = async () => {
queue: mainQueue,
useBackup: false,
db,
}),
})
);
workers.push(
initWorker({
@@ -231,7 +233,7 @@ export const initWorkers = async () => {
useBackup: true,
db,
}),
})
);
}

View File

@@ -4,153 +4,129 @@ export { schemas };
// Auth Models
export * from "./db/auth-schema.js";
export * from "./enums/APIVersion.js";
export * from "./enums/AttachErrCode.js";
export * from "./enums/ErrCode.js";
export * from "./enums/LoggerAction.js";
// ENUMS
export * from "./enums/SuccessCode.js";
export * from "./enums/WebhookEventType.js";
// ANALYTICS MODELS
export * from "./models/analyticsModels/actionEnums.js";
export * from "./models/analyticsModels/actionTable.js";
export * from "./models/attachModels/attachBody.js";
export * from "./models/attachModels/attachEnums/AttachBranch.js";
export * from "./models/attachModels/attachEnums/AttachConfig.js";
export * from "./models/attachModels/attachEnums/AttachFunction.js";
// Attach Models
export * from "./models/attachModels/attachPreviewModels.js";
export * from "./models/attachModels/checkoutModels.js";
export * from "./models/authModels/membership.js";
// Gen Models
export * from "./models/genModels/genEnums.js";
// 1. Org Models
export * from "./models/orgModels/orgTable.js";
export * from "./models/orgModels/orgConfig.js";
export * from "./models/orgModels/frontendOrg.js";
// 2. Feature Models
export * from "./models/featureModels/featureTable.js";
export * from "./models/chatResultModels/chatResultFeature.js";
export * from "./models/chatResultModels/chatResultFeature.js";
// 4. Chat Result Models
export * from "./models/chatResultModels/chatResultTable.js";
export * from "./models/checkModels/checkPreviewModels.js";
export * from "./models/cusModels/cusExpand.js";
// 8. Customer Models
export * from "./models/cusModels/cusModels.js";
export * from "./models/cusModels/cusResModels/cusFeatureResponse.js";
export * from "./models/cusModels/cusResModels/cusProductResponse.js";
export * from "./models/cusModels/cusResModels/cusReferralsResponse.js";
// Cus response
export * from "./models/cusModels/cusResponseModels.js";
export * from "./models/cusModels/cusTable.js";
export * from "./models/cusModels/entityModels/entityExpand.js";
export * from "./models/cusModels/entityModels/entityModels.js";
export * from "./models/cusModels/entityModels/entityResModels.js";
export * from "./models/cusModels/entityModels/entityTable.js";
export * from "./models/cusModels/fullCusModel.js";
export * from "./models/cusModels/invoiceModels/invoiceModels.js";
export * from "./models/cusModels/invoiceModels/invoiceResponseModels.js";
export * from "./models/cusModels/invoiceModels/invoiceTable.js";
export * from "./models/cusProductModels/cusEntModels/cusEntModels.js";
export * from "./models/cusProductModels/cusEntModels/cusEntTable.js";
export * from "./models/cusProductModels/cusEntModels/cusEntWithProduct.js";
export * from "./models/cusProductModels/cusEntModels/replaceableSchema.js";
export * from "./models/cusProductModels/cusEntModels/replaceableTable.js";
export * from "./models/cusProductModels/cusEntModels/resetCusEnt.js";
export * from "./models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.js";
export * from "./models/cusProductModels/cusPriceModels/cusPriceModels.js";
export * from "./models/cusProductModels/cusPriceModels/cusPriceTable.js";
export * from "./models/cusProductModels/cusProductEnums.js";
// 7. Cus Product Models
export * from "./models/cusProductModels/cusProductModels.js";
export * from "./models/cusProductModels/cusProductTable.js";
export * from "./models/devModels/apiKeyModels.js";
export * from "./models/devModels/apiKeyTable.js";
// 5. Others: events, apiKeys
export * from "./models/eventModels/eventModels.js";
export * from "./models/eventModels/eventTable.js";
export * from "./models/featureModels/featureConfig/creditConfig.js";
export * from "./models/featureModels/featureConfig/meteredConfig.js";
export * from "./models/featureModels/featureEnums.js";
export * from "./models/featureModels/featureModels.js";
export * from "./models/featureModels/featureResModels.js";
export * from "./models/featureModels/featureConfig/meteredConfig.js";
export * from "./models/featureModels/featureConfig/creditConfig.js";
// 2. Feature Models
export * from "./models/featureModels/featureTable.js";
// Gen Models
export * from "./models/genModels/genEnums.js";
export * from "./models/migrationModels/migrationErrorTable.js";
export * from "./models/migrationModels/migrationJobTable.js";
export * from "./models/migrationModels/migrationModels.js";
export * from "./models/orgModels/frontendOrg.js";
export * from "./models/orgModels/orgConfig.js";
// 1. Org Models
export * from "./models/orgModels/orgTable.js";
export * from "./models/otherModels/metadataModels.js";
export * from "./models/otherModels/metadataTable.js";
export * from "./models/productModels/entModels/entEnums.js";
export * from "./models/productModels/entModels/entModels.js";
// 3. Entitlement Models
export * from "./models/productModels/entModels/entTable.js";
export * from "./models/productModels/entModels/entModels.js";
export * from "./models/productModels/entModels/entEnums.js";
// 4. Free Trial Models
export * from "./models/productModels/freeTrialModels/freeTrialEnums.js";
export * from "./models/productModels/freeTrialModels/freeTrialModels.js";
export * from "./models/productModels/freeTrialModels/freeTrialTable.js";
// 4. Price Models
export * from "./models/productModels/priceModels/priceEnums.js";
export * from "./models/productModels/priceModels/priceConfig/fixedPriceConfig.js";
export * from "./models/productModels/priceModels/priceConfig/usagePriceConfig.js";
export * from "./models/productModels/priceModels/priceTable.js";
// 4. Price Models
export * from "./models/productModels/priceModels/priceEnums.js";
export * from "./models/productModels/priceModels/priceModels.js";
export * from "./models/productModels/priceModels/priceTable.js";
// 5. Product Models
export * from "./models/productModels/productEnums.js";
export * from "./models/productModels/productTable.js";
export * from "./models/productModels/productModels.js";
export * from "./models/productModels/productRelations.js";
// 6. Product V2 Models
export * from "./models/productV2Models/productV2Models.js";
export * from "./models/productV2Models/productResponseModels.js";
export * from "./models/productV2Models/productItemModels/productItemModels.js";
export * from "./models/productV2Models/productItemModels/prodItemResponseModels.js";
export * from "./models/productModels/productTable.js";
export * from "./models/productV2Models/productItemModels/featureItem.js";
export * from "./models/productV2Models/productItemModels/featurePriceItem.js";
export * from "./models/productV2Models/productItemModels/priceItem.js";
export * from "./models/productV2Models/productItemModels/prodItemResponseModels.js";
export * from "./models/productV2Models/productItemModels/productItemEnums.js";
// 7. Cus Product Models
export * from "./models/cusProductModels/cusProductModels.js";
export * from "./models/cusProductModels/cusProductTable.js";
export * from "./models/cusProductModels/cusProductEnums.js";
export * from "./models/cusProductModels/cusPriceModels/cusPriceModels.js";
export * from "./models/cusProductModels/cusPriceModels/cusPriceTable.js";
export * from "./models/cusProductModels/cusEntModels/cusEntModels.js";
export * from "./models/cusProductModels/cusEntModels/cusEntWithProduct.js";
export * from "./models/cusProductModels/cusEntModels/cusEntTable.js";
export * from "./models/cusProductModels/cusEntModels/replaceableTable.js";
export * from "./models/cusProductModels/cusEntModels/replaceableSchema.js";
export * from "./models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.js";
export * from "./models/cusProductModels/cusEntModels/resetCusEnt.js";
// 8. Customer Models
export * from "./models/cusModels/cusModels.js";
export * from "./models/cusModels/cusTable.js";
export * from "./models/cusModels/fullCusModel.js";
export * from "./models/cusModels/cusExpand.js";
export * from "./models/cusModels/invoiceModels/invoiceResponseModels.js";
export * from "./models/cusModels/invoiceModels/invoiceTable.js";
// Cus response
export * from "./models/cusModels/cusResponseModels.js";
export * from "./models/cusModels/cusResModels/cusProductResponse.js";
export * from "./models/cusModels/cusResModels/cusFeatureResponse.js";
export * from "./models/cusModels/cusResModels/cusReferralsResponse.js";
export * from "./models/cusModels/entityModels/entityModels.js";
export * from "./models/cusModels/entityModels/entityTable.js";
export * from "./models/cusModels/entityModels/entityExpand.js";
export * from "./models/cusModels/entityModels/entityResModels.js";
// 4. Chat Result Models
export * from "./models/chatResultModels/chatResultTable.js";
export * from "./models/chatResultModels/chatResultFeature.js";
// Reward Models
export * from "./models/rewardModels/rewardModels/rewardModels.js";
export * from "./models/rewardModels/rewardModels/rewardEnums.js";
export * from "./models/rewardModels/rewardModels/rewardTable.js";
export * from "./models/rewardModels/rewardModels/rewardResponseModels.js";
export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js";
export * from "./models/rewardModels/rewardProgramModels/rewardProgramEnums.js";
export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js";
export * from "./models/productV2Models/productItemModels/productItemModels.js";
export * from "./models/productV2Models/productResponseModels.js";
// 6. Product V2 Models
export * from "./models/productV2Models/productV2Models.js";
export * from "./models/rewardModels/referralModels/referralCodeTable.js";
export * from "./models/rewardModels/referralModels/referralModels.js";
export * from "./models/rewardModels/referralModels/rewardRedemptionTable.js";
export * from "./models/rewardModels/referralModels/referralCodeTable.js";
// 5. Others: events, apiKeys
export * from "./models/eventModels/eventModels.js";
export * from "./models/eventModels/eventTable.js";
export * from "./models/devModels/apiKeyModels.js";
export * from "./models/devModels/apiKeyTable.js";
export * from "./models/otherModels/metadataModels.js";
export * from "./models/otherModels/metadataTable.js";
export * from "./models/rewardModels/rewardModels/rewardEnums.js";
// Reward Models
export * from "./models/rewardModels/rewardModels/rewardModels.js";
export * from "./models/rewardModels/rewardModels/rewardResponseModels.js";
export * from "./models/rewardModels/rewardModels/rewardTable.js";
export * from "./models/rewardModels/rewardProgramModels/rewardProgramEnums.js";
export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js";
export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js";
export * from "./models/subModels/subModels.js";
export * from "./models/subModels/subTable.js";
export * from "./models/cusModels/invoiceModels/invoiceModels.js";
export * from "./models/migrationModels/migrationModels.js";
export * from "./models/migrationModels/migrationJobTable.js";
export * from "./models/migrationModels/migrationErrorTable.js";
// ANALYTICS MODELS
export * from "./models/analyticsModels/actionEnums.js";
export * from "./models/analyticsModels/actionTable.js";
// Attach Models
export * from "./models/attachModels/attachPreviewModels.js";
export * from "./models/attachModels/attachEnums/AttachBranch.js";
export * from "./models/attachModels/attachEnums/AttachFunction.js";
export * from "./models/attachModels/attachEnums/AttachConfig.js";
export * from "./models/attachModels/checkoutModels.js";
export * from "./models/attachModels/attachBody.js";
// Utils
export * from "./utils/displayUtils.js";
export * from "./models/checkModels/checkPreviewModels.js";
export * from "./models/chatResultModels/chatResultFeature.js";
export * from "./utils/productDisplayUtils/getProductItemRes.js";
export * from "./utils/productUtils.js";
export * from "./utils/productDisplayUtils/sortProductItems.js";
export * from "./utils/intervalUtils.js";
export * from "./utils/productUtils/priceToInvoiceAmount.js";
export * from "./utils/index.js";
// ENUMS
export * from "./enums/SuccessCode.js";
export * from "./enums/ErrCode.js";
export * from "./enums/LoggerAction.js";
export * from "./enums/AttachErrCode.js";
export * from "./enums/APIVersion.js";
export * from "./enums/WebhookEventType.js";
export * from "./utils/intervalUtils.js";
export * from "./utils/productDisplayUtils/getProductItemRes.js";
export * from "./utils/productDisplayUtils/sortProductItems.js";
export * from "./utils/productUtils/priceToInvoiceAmount.js";
export * from "./utils/productUtils.js";
export * from "./utils/rewardUtils/rewardMigrationUtils.js";

View File

@@ -13,6 +13,8 @@ export const UsageTierSchema = z.object({
amount: z.number(),
});
export type UsageTier = z.infer<typeof UsageTierSchema>;
export const UsagePriceConfigSchema = z.object({
type: z.string(),
bill_when: z.nativeEnum(BillWhen),

View File

@@ -0,0 +1,177 @@
import type {
FixedPriceConfig,
Price,
Reward,
RewardType,
UsagePriceConfig,
} from "../../index.js";
import type { UsageTier } from "../../models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import { isFixedPrice, isUsagePrice } from "../productUtils/priceUtils.js";
// Helper function to check if tier structures match
const tiersMatch = (oldTiers: UsageTier[], newTiers: UsageTier[]): boolean => {
if (oldTiers.length !== newTiers.length) return false;
return oldTiers.every((oldTier, index) => {
const newTier = newTiers[index];
return oldTier.to === newTier.to && oldTier.amount === newTier.amount;
});
};
// Match fixed prices by amount
const findMatchingFixedPrice = (
oldPrice: Price,
candidates: Price[],
): Price | null => {
const oldConfig = oldPrice.config as FixedPriceConfig;
return (
candidates.find((candidate) => {
const newConfig = candidate.config as FixedPriceConfig;
return newConfig.amount === oldConfig.amount;
}) || null
);
};
// Match usage prices by feature and billing characteristics
const findMatchingUsagePrice = (
oldPrice: Price,
candidates: Price[],
): Price | null => {
const oldConfig = oldPrice.config as UsagePriceConfig;
return (
candidates.find((candidate) => {
const newConfig = candidate.config as UsagePriceConfig;
// Match by feature
if (newConfig.feature_id !== oldConfig.feature_id) return false;
if (newConfig.internal_feature_id !== oldConfig.internal_feature_id)
return false;
// Match by billing behavior
if (newConfig.bill_when !== oldConfig.bill_when) return false;
if (newConfig.should_prorate !== oldConfig.should_prorate) return false;
// Optionally match by tier structure
if (!tiersMatch(oldConfig.usage_tiers, newConfig.usage_tiers))
return false;
return true;
}) || null
);
};
// Main matching function with type-specific logic
const findBestMatch = (oldPrice: Price, newPrices: Price[]): Price | null => {
// First, filter by basic characteristics
const candidates = newPrices.filter(
(newPrice) =>
newPrice.config.type === oldPrice.config.type &&
newPrice.config.interval === oldPrice.config.interval &&
newPrice.config.interval_count === oldPrice.config.interval_count,
);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
// If multiple candidates, use type-specific matching
if (isFixedPrice({ price: oldPrice })) {
return findMatchingFixedPrice(oldPrice, candidates);
} else if (isUsagePrice({ price: oldPrice })) {
return findMatchingUsagePrice(oldPrice, candidates);
}
// Fallback to first candidate
return candidates[0];
};
export interface RewardMigrationResult {
willMigrateCount: number;
willNotMigrateCount: number;
}
export interface RewardPriceAnalysisResult {
validPriceCount: number;
invalidPriceCount: number;
}
export function analyzeRewardMigration({
rewards,
oldPrices,
newPrices,
rewardTypesToCheck,
}: {
rewards: Reward[];
oldPrices: Price[];
newPrices: Price[];
rewardTypesToCheck: RewardType[];
}): RewardMigrationResult {
let willMigrateCount = 0;
let willNotMigrateCount = 0;
// Filter rewards to only those we care about and that have discount configs with price_ids
const relevantRewards = rewards.filter(
(reward) =>
rewardTypesToCheck.includes(reward.type) &&
reward.discount_config?.price_ids &&
reward.discount_config.price_ids.length > 0,
);
for (const reward of relevantRewards) {
if (!reward.discount_config?.price_ids) continue;
for (const priceId of reward.discount_config.price_ids) {
const oldPrice = oldPrices.find((p) => p.id === priceId);
if (!oldPrice) {
// Price not in old prices list, skip
continue;
}
const matchingNewPrice = findBestMatch(oldPrice, newPrices);
if (matchingNewPrice) {
willMigrateCount++;
} else {
willNotMigrateCount++;
}
}
}
return {
willMigrateCount,
willNotMigrateCount,
};
}
export function analyzeRewardPrices({
reward,
availablePriceIds,
}: {
reward: Reward;
availablePriceIds: string[];
}): RewardPriceAnalysisResult {
let validPriceCount = 0;
let invalidPriceCount = 0;
// Skip rewards that apply to all products
if (reward.discount_config?.apply_to_all) {
return { validPriceCount: 0, invalidPriceCount: 0 };
}
// Check each price ID in the reward
if (reward.discount_config?.price_ids) {
for (const priceId of reward.discount_config.price_ids) {
if (availablePriceIds.includes(priceId)) {
validPriceCount++;
} else {
invalidPriceCount++;
}
}
}
return {
validPriceCount,
invalidPriceCount,
};
}

View File

@@ -1,6 +1,6 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Feature } from "@autumn/shared";
import type { Feature } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useFeaturesQuery = () => {
const axiosInstance = useAxiosInstance();
@@ -17,5 +17,5 @@ export const useFeaturesQuery = () => {
queryFn: fetchFeatures,
});
return { features: data?.features || [], isLoading, error, refetch };
return { features: (data?.features || []) as Feature[], isLoading, error, refetch };
};

View File

@@ -1,6 +1,6 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { FullProduct, ProductCounts, ProductV2 } from "@autumn/shared";
import type { FullProduct, ProductCounts, ProductV2 } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useProductsQuery = () => {
const axiosInstance = useAxiosInstance();
@@ -31,7 +31,7 @@ export const useProductsQuery = () => {
});
return {
products: data?.products || [],
products: (data?.products || []) as ProductV2[],
counts: countsData || {},
groupToDefaults: data?.groupToDefaults || {},
isLoading,

View File

@@ -1,5 +1,6 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import type { Reward, RewardProgram } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useRewardsQuery = () => {
const axiosInstance = useAxiosInstance();
@@ -15,8 +16,8 @@ export const useRewardsQuery = () => {
});
return {
rewards: data?.rewards || [],
rewardPrograms: data?.rewardPrograms || [],
rewards: (data?.rewards || []) as Reward[],
rewardPrograms: (data?.rewardPrograms || []) as RewardProgram[],
isLoading,
error,
refetch,

View File

@@ -192,16 +192,14 @@ 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) => {
{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),
p.items.find((i: any) => i.price_id === priceId)
)
?.items.find((i: any) => i.price_id === priceId);
@@ -211,7 +209,7 @@ const ProductPriceSelector = ({
org,
features,
})
: "Deleted price";
: "Unknown Price";
return (
<div
key={priceId}
@@ -232,8 +230,6 @@ const ProductPriceSelector = ({
</div>
);
})}
</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 absolute right-2" />
</Button>
</PopoverTrigger>

View File

@@ -38,7 +38,6 @@ export const RewardConfig = ({
}) => {
const [idChanged, setIdChanged] = useState(false);
const { products } = useProductsQuery();
const { org } = useOrg();
useEffect(() => {
if (!idChanged) {
@@ -47,7 +46,7 @@ export const RewardConfig = ({
id: slugify(reward.name || ""),
});
}
}, [reward, idChanged, setReward]);
}, [idChanged, reward, setReward]);
return (
<div className="flex flex-col gap-4">
@@ -117,14 +116,9 @@ export const RewardConfig = ({
</div>
{reward.type === RewardType.FreeProduct ? (
<div>
<div>
<FieldLabel
description="Select a product to give away"
tooltip="If the referrer/redeemer already has the product, it will not be added to them."
>
<FieldLabel description="Select a free add-on product to give away">
Product
</FieldLabel>
</div>
<Select
value={reward.free_product_id || undefined}
onValueChange={(value) =>
@@ -132,39 +126,24 @@ export const RewardConfig = ({
}
>
{(() => {
const filteredProducts = [
// Paid products, no feature prices
...products
.filter((product: ProductV2) => !isFreeProduct(product.items))
.filter(
(product: ProductV2) =>
!product.items.some(
(x) =>
isFeaturePriceItem(x) &&
x.usage_model === UsageModel.Prepaid,
),
),
// Free add-ons
...products
const freeAddOns = products
.filter((product: ProductV2) => product.is_add_on)
.filter((product: ProductV2) => isFreeProduct(product.items)),
];
.filter((product: ProductV2) => isFreeProduct(product.items));
const empty = filteredProducts.length === 0;
const empty = freeAddOns.length === 0;
return (
<>
<SelectTrigger disabled={empty}>
<SelectValue
placeholder={
empty
? "Create a free add-on or paid product first"
? "Create a free add-on product first"
: "Select a product"
}
/>
</SelectTrigger>
<SelectContent>
{filteredProducts.map((product: ProductV2) => (
{freeAddOns.map((product: ProductV2) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
@@ -174,63 +153,10 @@ export const RewardConfig = ({
);
})()}
</Select>
{(() => {
const selectedProduct = products.find(
(p: ProductV2) => p.id === reward.free_product_id,
);
if (!selectedProduct) return null;
const isPaidSelected = !isFreeProduct(selectedProduct.items);
if (!isPaidSelected) return null;
const isRecurringSelected = !isOneOffProduct(selectedProduct.items);
const hasUsagePrices = selectedProduct.items.some(
(x) =>
isFeaturePriceItem(x) && x.usage_model === UsageModel.PayPerUse,
);
const priceItem = selectedProduct.items.find((x) => isPriceItem(x));
const currency = org?.default_currency || "USD";
const fixedAmountStr = priceItem?.price
? formatCurrency({ amount: priceItem.price, currency })
: undefined;
if (isRecurringSelected) {
return (
<div className="mt-3">
<WarningBox>
Users will receive a coupon equal to this product's fixed
price amount.{" "}
{fixedAmountStr
? `If they're on a different tier, they will receive ${fixedAmountStr} off.`
: "If they're on a different tier, they will receive the fixed amount off."}{" "}
{hasUsagePrices
? "Charges due to usage prices will not be included in the coupon."
: ""}
</WarningBox>
</div>
);
}
return;
})()}
</div>
) : notNullish(reward.type) ? (
<DiscountConfig reward={reward} setReward={setReward} />
) : null}
{reward.type === RewardType.FreeProduct &&
notNullish(reward.free_product_id) &&
reward.free_product_id &&
!isOneOffProduct(
products.find(
(product: ProductV2) => product.id === reward.free_product_id,
)?.items || [],
) ? (
<FreeDurationSelect reward={reward} setReward={setReward} />
) : null}
</div>
);
};

View File

@@ -1,21 +1,51 @@
import React, { useState } from "react";
import type { ProductV2, Reward } from "@autumn/shared";
import { analyzeRewardPrices } from "@autumn/shared";
import { useState } from "react";
import { toast } from "sonner";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { Reward } from "@autumn/shared";
import { useEnv } from "@/utils/envUtils";
import { RewardService } from "@/services/products/RewardService";
import { getBackendErr } from "@/utils/genUtils";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { RewardService } from "@/services/products/RewardService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { getBackendErr } from "@/utils/genUtils";
import { RewardConfig } from "./RewardConfig";
const checkRewardMigration = (
reward: Reward,
products: ProductV2[],
): { willMigrateCount: number; willNotMigrateCount: number } => {
// Extract all available price IDs from current products
const availablePriceIds: string[] = [];
for (const product of products) {
if (product.items) {
for (const item of product.items) {
if (item.price_id) {
availablePriceIds.push(item.price_id);
}
}
}
}
// Use the shared utility to analyze the reward
const analysis = analyzeRewardPrices({
reward,
availablePriceIds,
});
return {
willMigrateCount: analysis.validPriceCount,
willNotMigrateCount: analysis.invalidPriceCount,
};
};
function UpdateReward({
open,
setOpen,
@@ -29,17 +59,36 @@ function UpdateReward({
}) {
const [updateLoading, setUpdateLoading] = useState(false);
const { refetch } = useRewardsQuery();
const { products } = useProductsQuery();
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
if (!selectedReward) {
setOpen(false);
return;
}
const handleUpdate = async () => {
setUpdateLoading(true);
try {
// Check migration status and show warning if needed
if (products) {
const migrationResult = checkRewardMigration(selectedReward, products);
if (migrationResult.willNotMigrateCount > 0) {
toast.warning(
`${migrationResult.willNotMigrateCount} price${migrationResult.willNotMigrateCount === 1 ? "" : "s"} won't be migrated to the latest product version.`,
{
duration: 5000,
},
);
}
}
await RewardService.updateReward({
axiosInstance,
internalId: selectedReward!.internal_id,
data: selectedReward!,
internalId: selectedReward.internal_id,
data: selectedReward,
});
toast.success("Reward updated successfully");
await refetch();