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,142 +1,156 @@
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 ({
req,
res,
latestProduct,
org,
env,
items,
freeTrial,
req,
res,
latestProduct,
org,
env,
items,
freeTrial,
}: {
req: any;
res: any;
latestProduct: FullProduct;
org: Organization;
env: AppEnv;
items: ProductItem[];
freeTrial: FreeTrial;
req: any;
res: any;
latestProduct: FullProduct;
org: Organization;
env: AppEnv;
items: ProductItem[];
freeTrial: FreeTrial;
}) => {
const { db } = req;
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}`
);
console.log(
`Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}`,
);
const newProduct = constructProduct({
productData: CreateProductSchema.parse({
...latestProduct,
...req.body,
version: newVersion,
}),
orgId: org.id,
env: latestProduct.env as AppEnv,
processor: latestProduct.processor,
baseVariantId: latestProduct.base_variant_id,
});
const newProduct = constructProduct({
productData: CreateProductSchema.parse({
...latestProduct,
...req.body,
version: newVersion,
}),
orgId: org.id,
env: latestProduct.env as AppEnv,
processor: latestProduct.processor,
baseVariantId: latestProduct.base_variant_id,
});
// Validate product items...
validateProductItems({
newItems: items,
features,
orgId: org.id,
env,
});
// Validate product items...
validateProductItems({
newItems: items,
features,
orgId: org.id,
env,
});
if (latestProduct.is_default) {
await ProductService.updateByInternalId({
db,
internalId: latestProduct.internal_id,
update: {
is_default: false,
},
});
}
if (latestProduct.is_default) {
await ProductService.updateByInternalId({
db,
internalId: latestProduct.internal_id,
update: {
is_default: false,
},
});
}
await ProductService.insert({ db, product: newProduct });
await ProductService.insert({ db, product: newProduct });
const { customPrices, customEnts } = await handleNewProductItems({
db,
curPrices: latestProduct.prices,
curEnts: latestProduct.entitlements,
newItems: items,
features,
product: newProduct,
logger: console,
isCustom: false,
newVersion: true,
});
const { customPrices, customEnts } = await handleNewProductItems({
db,
curPrices: latestProduct.prices,
curEnts: latestProduct.entitlements,
newItems: items,
features,
product: newProduct,
logger: console,
isCustom: false,
newVersion: true,
});
await EntitlementService.insert({
db,
data: customEnts,
});
await EntitlementService.insert({
db,
data: customEnts,
});
await PriceService.insert({
db,
data: customPrices,
});
await PriceService.insert({
db,
data: customPrices,
});
// Handle new free trial
if (freeTrial) {
await handleNewFreeTrial({
db,
newFreeTrial: freeTrial,
curFreeTrial: null,
internalProductId: newProduct.internal_id,
isCustom: false,
});
}
// Handle new free trial
if (freeTrial) {
await handleNewFreeTrial({
db,
newFreeTrial: freeTrial,
curFreeTrial: null,
internalProductId: newProduct.internal_id,
isCustom: false,
});
}
// await addTaskToQueue({
// jobName: JobName.DetectBaseVariant,
// payload: {
// curProduct: {
// ...newProduct,
// // prices: customPrices,
// // entitlements: getEntsWithFeature({ ents: customEnts, features }),
// },
// },
// });
// await addTaskToQueue({
// jobName: JobName.DetectBaseVariant,
// payload: {
// curProduct: {
// ...newProduct,
// // prices: customPrices,
// // entitlements: getEntsWithFeature({ ents: customEnts, features }),
// },
// },
// });
await initProductInStripe({
db,
product: {
...newProduct,
prices: customPrices,
entitlements: getEntsWithFeature({ ents: customEnts, features }),
} as FullProduct,
org,
env,
logger: console,
});
await initProductInStripe({
db,
product: {
...newProduct,
prices: customPrices,
entitlements: getEntsWithFeature({ ents: customEnts, features }),
} as FullProduct,
org,
env,
logger: console,
});
res.status(200).send(newProduct);
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,164 +1,176 @@
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";
export class RewardService {
static async get({
db,
idOrInternalId,
orgId,
env,
}: {
db: DrizzleCli;
idOrInternalId: string;
orgId: string;
env: AppEnv;
}) {
const result = await db.query.rewards.findFirst({
where: and(
or(
eq(rewards.id, idOrInternalId),
eq(rewards.internal_id, idOrInternalId),
),
eq(rewards.org_id, orgId),
eq(rewards.env, env),
),
});
static async get({
db,
idOrInternalId,
orgId,
env,
}: {
db: DrizzleCli;
idOrInternalId: string;
orgId: string;
env: AppEnv;
}) {
const result = await db.query.rewards.findFirst({
where: and(
or(
eq(rewards.id, idOrInternalId),
eq(rewards.internal_id, idOrInternalId)
),
eq(rewards.org_id, orgId),
eq(rewards.env, env)
),
});
if (!result) {
return null;
}
if (!result) {
return null;
}
return result as Reward;
}
return result as Reward;
}
static async getByIdOrCode({
db,
codes,
orgId,
env,
}: {
db: DrizzleCli;
codes: string[];
orgId: string;
env: AppEnv;
}) {
const reward = await db.query.rewards.findMany({
where: and(
eq(rewards.org_id, orgId),
eq(rewards.env, env),
or(
inArray(rewards.id, codes),
...codes.map(
(code) => sql`EXISTS (
static async getByIdOrCode({
db,
codes,
orgId,
env,
}: {
db: DrizzleCli;
codes: string[];
orgId: string;
env: AppEnv;
}) {
const reward = await db.query.rewards.findMany({
where: and(
eq(rewards.org_id, orgId),
eq(rewards.env, env),
or(
inArray(rewards.id, codes),
...codes.map(
(code) => sql`EXISTS (
SELECT 1 FROM unnest("promo_codes") AS elem
WHERE elem->>'code' = ${code}
)`,
),
),
),
});
)`
)
)
),
});
return reward as Reward[];
}
return reward as Reward[];
}
static async insert({
db,
data,
}: {
db: DrizzleCli;
data: Reward | Reward[];
}) {
const results = await db.insert(rewards).values(data as Reward);
return results as Reward[];
}
static async insert({
db,
data,
}: {
db: DrizzleCli;
data: Reward | Reward[];
}) {
const results = await db.insert(rewards).values(data as Reward);
return results as Reward[];
}
static async list({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}) {
const results = await db.query.rewards.findMany({
where: and(eq(rewards.org_id, orgId), eq(rewards.env, env)),
orderBy: [desc(rewards.internal_id)],
});
static async list({
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),
inTypes ? inArray(rewards.type, inTypes) : undefined
),
orderBy: [desc(rewards.internal_id)],
});
return results as Reward[];
}
return results as Reward[];
}
static async delete({
db,
internalId,
env,
orgId,
}: {
db: DrizzleCli;
internalId: string;
env: AppEnv;
orgId: string;
}) {
await db
.delete(rewards)
.where(
and(
eq(rewards.internal_id, internalId),
eq(rewards.env, env),
eq(rewards.org_id, orgId),
),
);
}
static async delete({
db,
internalId,
env,
orgId,
}: {
db: DrizzleCli;
internalId: string;
env: AppEnv;
orgId: string;
}) {
await db
.delete(rewards)
.where(
and(
eq(rewards.internal_id, internalId),
eq(rewards.env, env),
eq(rewards.org_id, orgId)
)
);
}
static async update({
db,
internalId,
env,
orgId,
update,
}: {
db: DrizzleCli;
internalId: string;
env: AppEnv;
orgId: string;
update: Partial<Reward>;
}) {
const result = await db
.update(rewards)
.set(update)
.where(
and(
eq(rewards.internal_id, internalId),
eq(rewards.env, env),
eq(rewards.org_id, orgId),
),
)
.returning();
static async update({
db,
internalId,
env,
orgId,
update,
}: {
db: DrizzleCli;
internalId: string;
env: AppEnv;
orgId: string;
update: Partial<Reward>;
}) {
const result = await db
.update(rewards)
.set(update)
.where(
and(
eq(rewards.internal_id, internalId),
eq(rewards.env, env),
eq(rewards.org_id, orgId)
)
)
.returning();
if (result.length === 0) {
throw new RecaseError({
message: `Reward ${internalId} not found`,
code: ErrCode.InvalidRequest,
});
}
if (result.length === 0) {
throw new RecaseError({
message: `Reward ${internalId} not found`,
code: ErrCode.InvalidRequest,
});
}
return result[0] as Reward;
}
return result[0] as Reward;
}
static async deleteByOrgId({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}) {
await db
.delete(rewards)
.where(and(eq(rewards.org_id, orgId), eq(rewards.env, env)));
}
static async deleteByOrgId({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}) {
await db
.delete(rewards)
.where(and(eq(rewards.org_id, orgId), eq(rewards.env, env)));
}
}

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,228 +14,230 @@ 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;
const actionHandlers = [
JobName.HandleProductsUpdated,
JobName.HandleCustomerCreated,
JobName.HandleProductsUpdated,
JobName.HandleCustomerCreated,
];
const { db, client } = initDrizzle({ maxConnections: 10 });
const { db } = initDrizzle({ maxConnections: 10 });
const initWorker = ({
id,
queue,
useBackup,
db,
id,
queue,
useBackup,
db,
}: {
id: number;
queue: Queue;
useBackup: boolean;
db: DrizzleCli;
id: number;
queue: Queue;
useBackup: boolean;
db: DrizzleCli;
}) => {
const worker = new Worker(
"autumn",
async (job: Job) => {
const logtail = logger.child({
context: {
worker: {
task: job.name,
data: job.data,
jobId: generateId("job"),
workerId: id,
},
},
});
const worker = new Worker(
"autumn",
async (job: Job) => {
const logtail = logger.child({
context: {
worker: {
task: job.name,
data: job.data,
jobId: generateId("job"),
workerId: id,
},
},
});
try {
if (job.name === JobName.DetectBaseVariant) {
await detectBaseVariant({
db,
curProduct: job.data.curProduct,
logger: logtail as Logger,
});
return;
}
try {
if (job.name === JobName.DetectBaseVariant) {
await detectBaseVariant({
db,
curProduct: job.data.curProduct,
logger: logtail as Logger,
});
return;
}
if (job.name === JobName.GenerateFeatureDisplay) {
await runSaveFeatureDisplayTask({
db,
feature: job.data.feature,
logger: logtail,
});
return;
}
if (job.name === JobName.GenerateFeatureDisplay) {
await runSaveFeatureDisplayTask({
db,
feature: job.data.feature,
logger: logtail,
});
return;
}
if (job.name === JobName.Migration) {
await runMigrationTask({
db,
payload: job.data,
logger: logtail,
});
return;
}
if (job.name === JobName.Migration) {
await runMigrationTask({
db,
payload: job.data,
logger: logtail,
});
return;
}
if (actionHandlers.includes(job.name as JobName)) {
await runActionHandlerTask({
queue,
job,
logger: logtail,
db,
useBackup,
});
return;
}
} catch (error: any) {
logtail.error(`Failed to process bullmq job: ${job.name}`, {
jobName: job.name,
error: {
message: error.message,
stack: error.stack,
},
});
}
if (actionHandlers.includes(job.name as JobName)) {
await runActionHandlerTask({
queue,
job,
logger: logtail,
db,
useBackup,
});
return;
}
// TRIGGER CHECKOUT REWARD
if (job.name === JobName.TriggerCheckoutReward) {
const lockKey = `reward_trigger:${job.data.customer?.internal_id}`;
if (
!(await acquireLock({
lockKey,
timeout: 10000,
useBackup,
}))
) {
await queue.add(job.name, job.data, {
delay: 1000,
});
logger.info(
"Lock not acquired for checkout reward, adding task to queue",
);
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,
error: {
message: error.message,
stack: error.stack,
},
});
}
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);
} finally {
logger.info("Releasing lock for checkout reward");
await releaseLock({ lockKey, useBackup });
logger.info("Lock released for checkout reward");
}
// TRIGGER CHECKOUT REWARD
if (job.name === JobName.TriggerCheckoutReward) {
const lockKey = `reward_trigger:${job.data.customer?.internal_id}`;
if (
!(await acquireLock({
lockKey,
timeout: 10000,
useBackup,
}))
) {
await queue.add(job.name, job.data, {
delay: 1000,
});
return;
}
return;
}
try {
await runTriggerCheckoutReward({
db,
payload: job.data,
logger: logtail,
});
} catch (error) {
console.error("Error processing job:", error);
} finally {
await releaseLock({ lockKey, useBackup });
}
// EVENT HANDLERS
const { internalCustomerId } = job.data; // customerId is internal customer id
return;
}
while (
!(await acquireLock({
lockKey: `event:${internalCustomerId}`,
timeout: 10000,
useBackup,
}))
) {
await queue.add(job.name, job.data, {
delay: 200,
});
return;
}
// EVENT HANDLERS
const { internalCustomerId } = job.data; // customerId is internal customer id
try {
if (job.name === JobName.UpdateBalance) {
await runUpdateBalanceTask({
payload: job.data,
logger: logtail,
db,
});
} else if (job.name === JobName.UpdateUsage) {
await runUpdateUsageTask({
payload: job.data,
logger: logtail,
db,
});
}
} catch (error) {
console.error("Error processing job:", error);
} finally {
await releaseLock({
lockKey: `event:${internalCustomerId}`,
useBackup,
});
}
},
{
...getRedisConnection({ useBackup }),
concurrency: 1,
removeOnComplete: {
count: 0,
},
removeOnFail: {
count: 0,
},
drainDelay: 1000,
maxStalledCount: 0,
},
);
while (
!(await acquireLock({
lockKey: `event:${internalCustomerId}`,
timeout: 10000,
useBackup,
}))
) {
await queue.add(job.name, job.data, {
delay: 200,
});
return;
}
worker.on("ready", () => {
console.log(`Worker ${id} ready (${useBackup ? "BACKUP" : "MAIN"})`);
});
try {
if (job.name === JobName.UpdateBalance) {
await runUpdateBalanceTask({
payload: job.data,
logger: logtail,
db,
});
} else if (job.name === JobName.UpdateUsage) {
await runUpdateUsageTask({
payload: job.data,
logger: logtail,
db,
});
}
} catch (error) {
console.error("Error processing job:", error);
} finally {
await releaseLock({
lockKey: `event:${internalCustomerId}`,
useBackup,
});
}
},
{
...getRedisConnection({ useBackup }),
concurrency: 1,
removeOnComplete: {
count: 0,
},
removeOnFail: {
count: 0,
},
drainDelay: 1000,
maxStalledCount: 0,
}
);
worker.on("stalled", (jobId: string) => {
console.log(`Worker ${id} stalled (${useBackup ? "BACKUP" : "MAIN"})`);
console.log("JOB ID:", jobId);
});
worker.on("ready", () => {
console.log(`Worker ${id} ready (${useBackup ? "BACKUP" : "MAIN"})`);
});
worker.on("error", async (error: any) => {
if (error.code !== "ECONNREFUSED") {
console.log("WORKER ERROR:", error.message);
}
});
worker.on("stalled", (jobId: string) => {
console.log(`Worker ${id} stalled (${useBackup ? "BACKUP" : "MAIN"})`);
console.log("JOB ID:", jobId);
});
worker.on("failed", (job, error) => {
console.log("WORKER FAILED:", error.message);
});
worker.on("error", async (error: any) => {
if (error.code !== "ECONNREFUSED") {
console.log("WORKER ERROR:", error.message);
}
});
worker.on("failed", (_, error) => {
console.log("WORKER FAILED:", error.message);
});
};
export const initWorkers = async () => {
const workers = [];
const workers = [];
const mainQueue = await QueueManager.getQueue({ useBackup: false });
const backupQueue = await QueueManager.getQueue({ useBackup: true });
await CacheManager.getInstance();
const mainQueue = await QueueManager.getQueue({ useBackup: false });
const backupQueue = await QueueManager.getQueue({ useBackup: true });
await CacheManager.getInstance();
for (let i = 0; i < NUM_WORKERS; i++) {
workers.push(
initWorker({
id: i,
queue: mainQueue,
useBackup: false,
db,
}),
);
workers.push(
initWorker({
id: i,
queue: backupQueue,
useBackup: true,
for (let i = 0; i < NUM_WORKERS; i++) {
workers.push(
initWorker({
id: i,
queue: mainQueue,
useBackup: false,
db,
})
);
workers.push(
initWorker({
id: i,
queue: backupQueue,
useBackup: true,
db,
}),
);
}
db,
})
);
}
// Get stalled jobs
// Get stalled jobs
return workers;
return workers;
};

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,21 +1,21 @@
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();
const axiosInstance = useAxiosInstance();
const fetchFeatures = async () => {
const { data } = await axiosInstance.get("/products/features");
return data;
};
const fetchFeatures = async () => {
const { data } = await axiosInstance.get("/products/features");
return data;
};
const { data, isLoading, error, refetch } = useQuery<{
features: Feature[];
}>({
queryKey: ["features"],
queryFn: fetchFeatures,
});
const { data, isLoading, error, refetch } = useQuery<{
features: Feature[];
}>({
queryKey: ["features"],
queryFn: fetchFeatures,
});
return { features: data?.features || [], isLoading, error, refetch };
return { features: (data?.features || []) as Feature[], isLoading, error, refetch };
};

View File

@@ -1,46 +1,46 @@
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();
const axiosInstance = useAxiosInstance();
const fetchProducts = async () => {
const { data } = await axiosInstance.get("/products/products");
return data;
};
const fetchProducts = async () => {
const { data } = await axiosInstance.get("/products/products");
return data;
};
const fetchProductCounts = async () => {
const { data } = await axiosInstance.get("/products/product_counts");
return data;
};
const fetchProductCounts = async () => {
const { data } = await axiosInstance.get("/products/product_counts");
return data;
};
const { data, isLoading, error, refetch } = useQuery<{
products: ProductV2[];
groupToDefaults: Record<string, Record<string, FullProduct>>;
}>({
queryKey: ["products"],
queryFn: fetchProducts,
});
const { data, isLoading, error, refetch } = useQuery<{
products: ProductV2[];
groupToDefaults: Record<string, Record<string, FullProduct>>;
}>({
queryKey: ["products"],
queryFn: fetchProducts,
});
const { data: countsData, refetch: countsRefetch } = useQuery<
Record<string, ProductCounts>
>({
queryKey: ["product_counts"],
queryFn: fetchProductCounts,
});
const { data: countsData, refetch: countsRefetch } = useQuery<
Record<string, ProductCounts>
>({
queryKey: ["product_counts"],
queryFn: fetchProductCounts,
});
return {
products: data?.products || [],
counts: countsData || {},
groupToDefaults: data?.groupToDefaults || {},
isLoading,
error,
refetch: async () => {
await Promise.all([countsRefetch(), refetch()]);
},
// mutate: async () => {
// await Promise.all([countsRefetch(), refetch()]);
// },
};
return {
products: (data?.products || []) as ProductV2[],
counts: countsData || {},
groupToDefaults: data?.groupToDefaults || {},
isLoading,
error,
refetch: async () => {
await Promise.all([countsRefetch(), refetch()]);
},
// mutate: async () => {
// await Promise.all([countsRefetch(), refetch()]);
// },
};
};

View File

@@ -1,24 +1,25 @@
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();
const axiosInstance = useAxiosInstance();
const fetchRewards = async () => {
const { data } = await axiosInstance.get("/products/rewards");
return data;
};
const fetchRewards = async () => {
const { data } = await axiosInstance.get("/products/rewards");
return data;
};
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["rewards"],
queryFn: fetchRewards,
});
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["rewards"],
queryFn: fetchRewards,
});
return {
rewards: data?.rewards || [],
rewardPrograms: data?.rewardPrograms || [],
isLoading,
error,
refetch,
};
};
return {
rewards: (data?.rewards || []) as Reward[],
rewardPrograms: (data?.rewardPrograms || []) as RewardProgram[],
isLoading,
error,
refetch,
};
};

View File

@@ -38,93 +38,93 @@ import { isFeatureItem } from "@/utils/product/getItemType";
import { formatProductItemText } from "@/utils/product/product-item/formatProductItem";
export const DiscountConfig = ({
reward,
setReward,
reward,
setReward,
}: {
reward: Reward;
setReward: (reward: Reward) => void;
reward: Reward;
setReward: (reward: Reward) => void;
}) => {
const { org } = useOrg();
const { org } = useOrg();
const config = reward.discount_config!;
const setConfig = (key: any, value: any) => {
setReward({
...reward,
discount_config: { ...config, [key]: value },
});
};
const config = reward.discount_config!;
const setConfig = (key: any, value: any) => {
setReward({
...reward,
discount_config: { ...config, [key]: value },
});
};
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Amount</FieldLabel>
<Input
value={config.discount_value}
onChange={(e) =>
setConfig("discount_value", Number(e.target.value))
}
endContent={
<p className="text-t3">
{reward.type === RewardType.PercentageDiscount
? "%"
: org?.default_currency || "USD"}
</p>
}
/>
</div>
<div className="w-6/12">
<FieldLabel>Duration</FieldLabel>
<div className="flex items-center gap-1">
{config.duration_type === CouponDurationType.Months && (
<Input
className="w-[60px] no-spinner"
value={config.duration_value}
onChange={(e) => {
setConfig("duration_value", Number(e.target.value));
}}
type="number"
/>
)}
<Select
value={config.duration_type}
onValueChange={(value) =>
setConfig("duration_type", value as CouponDurationType)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a duration" />
</SelectTrigger>
<SelectContent>
{Object.values(CouponDurationType)
.filter((type) => {
if (
reward.type === RewardType.FixedDiscount &&
type === CouponDurationType.Forever &&
config.duration_type !== CouponDurationType.Forever
) {
return false;
}
if (
reward.type === RewardType.InvoiceCredits &&
type === CouponDurationType.OneOff
) {
return false;
}
return true;
})
.map((type) => (
<SelectItem key={type} value={type}>
{keyToTitle(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Amount</FieldLabel>
<Input
value={config.discount_value}
onChange={(e) =>
setConfig("discount_value", Number(e.target.value))
}
endContent={
<p className="text-t3">
{reward.type === RewardType.PercentageDiscount
? "%"
: org?.default_currency || "USD"}
</p>
}
/>
</div>
<div className="w-6/12">
<FieldLabel>Duration</FieldLabel>
<div className="flex items-center gap-1">
{config.duration_type === CouponDurationType.Months && (
<Input
className="w-[60px] no-spinner"
value={config.duration_value}
onChange={(e) => {
setConfig("duration_value", Number(e.target.value));
}}
type="number"
/>
)}
<Select
value={config.duration_type}
onValueChange={(value) =>
setConfig("duration_type", value as CouponDurationType)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a duration" />
</SelectTrigger>
<SelectContent>
{Object.values(CouponDurationType)
.filter((type) => {
if (
reward.type === RewardType.FixedDiscount &&
type === CouponDurationType.Forever &&
config.duration_type !== CouponDurationType.Forever
) {
return false;
}
if (
reward.type === RewardType.InvoiceCredits &&
type === CouponDurationType.OneOff
) {
return false;
}
return true;
})
.map((type) => (
<SelectItem key={type} value={type}>
{keyToTitle(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* {config.duration_type !== CouponDurationType.OneOff &&
{/* {config.duration_type !== CouponDurationType.OneOff &&
reward.type === RewardType.FixedDiscount && (
<div className="w-full ml-1 flex items-center gap-2">
<Checkbox
@@ -137,164 +137,160 @@ export const DiscountConfig = ({
</div>
)} */}
<div className="">
{/* <p className="text-t2 mb-2 text-t3">Products</p> */}
<FieldLabel>Products</FieldLabel>
<div className="">
{/* <p className="text-t2 mb-2 text-t3">Products</p> */}
<FieldLabel>Products</FieldLabel>
<ProductPriceSelector reward={reward} setReward={setReward} />
</div>
</div>
);
<ProductPriceSelector reward={reward} setReward={setReward} />
</div>
</div>
);
};
const ProductPriceSelector = ({
reward,
setReward,
reward,
setReward,
}: {
reward: Reward;
setReward: (reward: Reward) => void;
reward: Reward;
setReward: (reward: Reward) => void;
}) => {
const { org } = useOrg();
const { products } = useProductsQuery();
const { features } = useFeaturesQuery();
const { org } = useOrg();
const { products } = useProductsQuery();
const { features } = useFeaturesQuery();
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
const config = reward.discount_config!;
const setConfig = (key: any, value: any) => {
setReward({
...reward,
discount_config: { ...config, [key]: value },
});
};
const config = reward.discount_config!;
const setConfig = (key: any, value: any) => {
setReward({
...reward,
discount_config: { ...config, [key]: value },
});
};
// Handle selection/deselection of a price
const handlePriceToggle = (priceId: string) => {
let newPriceIds = [...(config.price_ids || [])];
if (config.price_ids?.includes(priceId)) {
newPriceIds = config.price_ids?.filter((id) => id !== priceId) || [];
} else {
newPriceIds = [...(config.price_ids || []), priceId];
}
setConfig("price_ids", newPriceIds);
};
// Handle selection/deselection of a price
const handlePriceToggle = (priceId: string) => {
let newPriceIds = [...(config.price_ids || [])];
if (config.price_ids?.includes(priceId)) {
newPriceIds = config.price_ids?.filter((id) => id !== priceId) || [];
} else {
newPriceIds = [...(config.price_ids || []), priceId];
}
setConfig("price_ids", newPriceIds);
};
if (!products || products.length === 0) {
return <p className="text-sm text-t3">No products available</p>;
}
if (!products || products.length === 0) {
return <p className="text-sm text-t3">No products available</p>;
}
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
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);
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
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);
const text = item
? formatProductItemText({
item,
org,
features,
})
: "Deleted 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">
<Command>
<CommandInput placeholder="Search prices..." className="h-9" />
<CommandList className="max-h-[300px] overflow-y-auto">
<ScrollArea>
<CommandEmpty>No prices found.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
setConfig("apply_to_all", !config.apply_to_all);
}}
className="cursor-pointer"
>
<p>Apply to all products</p>
{config.apply_to_all && (
<Check size={12} className="text-t3" />
)}
</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) => (
<CommandItem
key={item.price_id}
value={item.price_id}
onSelect={() => handlePriceToggle(item.price_id)}
className="cursor-pointer overflow-x-hidden max-w-[380px]"
>
<span className="truncate overflow-x-hidden">
{formatProductItemText({
item,
org,
features,
})}
</span>
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">
<Command>
<CommandInput placeholder="Search prices..." className="h-9" />
<CommandList className="max-h-[300px] overflow-y-auto">
<ScrollArea>
<CommandEmpty>No prices found.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
setConfig("apply_to_all", !config.apply_to_all);
}}
className="cursor-pointer"
>
<p>Apply to all products</p>
{config.apply_to_all && (
<Check size={12} className="text-t3" />
)}
</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) => (
<CommandItem
key={item.price_id}
value={item.price_id}
onSelect={() => handlePriceToggle(item.price_id)}
className="cursor-pointer overflow-x-hidden max-w-[380px]"
>
<span className="truncate overflow-x-hidden">
{formatProductItemText({
item,
org,
features,
})}
</span>
{config.price_ids?.includes(item.price_id) && (
<Check size={12} className="text-t3" />
)}
</CommandItem>
))
) : (
<CommandItem disabled>
<p className="text-sm text-t3">No prices available</p>
</CommandItem>
)}
</CommandGroup>
))}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
{config.price_ids?.includes(item.price_id) && (
<Check size={12} className="text-t3" />
)}
</CommandItem>
))
) : (
<CommandItem disabled>
<p className="text-sm text-t3">No prices available</p>
</CommandItem>
)}
</CommandGroup>
))}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

View File

@@ -30,207 +30,133 @@ import { DiscountConfig } from "./DiscountConfig";
import { FreeDurationSelect } from "./FreeDurationSelect";
export const RewardConfig = ({
reward,
setReward,
reward,
setReward,
}: {
reward: Reward;
setReward: (reward: Reward) => void;
reward: Reward;
setReward: (reward: Reward) => void;
}) => {
const [idChanged, setIdChanged] = useState(false);
const { products } = useProductsQuery();
const { org } = useOrg();
const [idChanged, setIdChanged] = useState(false);
const { products } = useProductsQuery();
useEffect(() => {
if (!idChanged) {
setReward({
...reward,
id: slugify(reward.name || ""),
});
}
}, [reward, idChanged, setReward]);
useEffect(() => {
if (!idChanged) {
setReward({
...reward,
id: slugify(reward.name || ""),
});
}
}, [idChanged, reward, setReward]);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel description="Will be shown on receipt">Name</FieldLabel>
<Input
value={reward.name || ""}
onChange={(e) => setReward({ ...reward, name: e.target.value })}
/>
</div>
<div className="w-6/12">
<FieldLabel description="Used to identify reward in API">
ID
</FieldLabel>
<Input
value={reward.id || ""}
onChange={(e) => {
setReward({ ...reward, id: e.target.value });
setIdChanged(true);
}}
/>
</div>
</div>
<div className="flex items-center w-full gap-2">
<div className="w-full">
<FieldLabel>Promotional Code</FieldLabel>
<Input
value={
reward.promo_codes.length > 0 ? reward.promo_codes[0].code : ""
}
onChange={(e) =>
setReward({
...reward,
promo_codes: [{ code: e.target.value }],
})
}
/>
</div>
<div className="w-full">
<FieldLabel>Type</FieldLabel>
<Select
value={reward.type}
onValueChange={(value) => {
setReward({
...reward,
type: value as RewardType,
discount_config:
value === RewardType.FreeProduct
? null
: defaultDiscountConfig,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a discount type" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardType).map((type) => (
<SelectItem key={type} value={type}>
{keyToTitle(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</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."
>
Product
</FieldLabel>
</div>
<Select
value={reward.free_product_id || undefined}
onValueChange={(value) =>
setReward({ ...reward, free_product_id: value })
}
>
{(() => {
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,
),
),
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel description="Will be shown on receipt">Name</FieldLabel>
<Input
value={reward.name || ""}
onChange={(e) => setReward({ ...reward, name: e.target.value })}
/>
</div>
<div className="w-6/12">
<FieldLabel description="Used to identify reward in API">
ID
</FieldLabel>
<Input
value={reward.id || ""}
onChange={(e) => {
setReward({ ...reward, id: e.target.value });
setIdChanged(true);
}}
/>
</div>
</div>
<div className="flex items-center w-full gap-2">
<div className="w-full">
<FieldLabel>Promotional Code</FieldLabel>
<Input
value={
reward.promo_codes.length > 0 ? reward.promo_codes[0].code : ""
}
onChange={(e) =>
setReward({
...reward,
promo_codes: [{ code: e.target.value }],
})
}
/>
</div>
<div className="w-full">
<FieldLabel>Type</FieldLabel>
<Select
value={reward.type}
onValueChange={(value) => {
setReward({
...reward,
type: value as RewardType,
discount_config:
value === RewardType.FreeProduct
? null
: defaultDiscountConfig,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a discount type" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardType).map((type) => (
<SelectItem key={type} value={type}>
{keyToTitle(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{reward.type === RewardType.FreeProduct ? (
<div>
<FieldLabel description="Select a free add-on product to give away">
Product
</FieldLabel>
<Select
value={reward.free_product_id || undefined}
onValueChange={(value) =>
setReward({ ...reward, free_product_id: value })
}
>
{(() => {
const freeAddOns = products
.filter((product: ProductV2) => product.is_add_on)
.filter((product: ProductV2) => isFreeProduct(product.items));
// Free add-ons
...products
.filter((product: ProductV2) => product.is_add_on)
.filter((product: ProductV2) => isFreeProduct(product.items)),
];
const empty = filteredProducts.length === 0;
return (
<>
<SelectTrigger disabled={empty}>
<SelectValue
placeholder={
empty
? "Create a free add-on or paid product first"
: "Select a product"
}
/>
</SelectTrigger>
<SelectContent>
{filteredProducts.map((product: ProductV2) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</>
);
})()}
</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>
);
const empty = freeAddOns.length === 0;
return (
<>
<SelectTrigger disabled={empty}>
<SelectValue
placeholder={
empty
? "Create a free add-on product first"
: "Select a product"
}
/>
</SelectTrigger>
<SelectContent>
{freeAddOns.map((product: ProductV2) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</>
);
})()}
</Select>
</div>
) : notNullish(reward.type) ? (
<DiscountConfig reward={reward} setReward={setReward} />
) : null}
</div>
);
};

View File

@@ -1,79 +1,128 @@
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,
selectedReward,
setSelectedReward,
open,
setOpen,
selectedReward,
setSelectedReward,
}: {
open: boolean;
setOpen: (open: boolean) => void;
selectedReward: Reward | null;
setSelectedReward: (reward: Reward) => void;
open: boolean;
setOpen: (open: boolean) => void;
selectedReward: Reward | null;
setSelectedReward: (reward: Reward) => void;
}) {
const [updateLoading, setUpdateLoading] = useState(false);
const { refetch } = useRewardsQuery();
const [updateLoading, setUpdateLoading] = useState(false);
const { refetch } = useRewardsQuery();
const { products } = useProductsQuery();
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
const handleUpdate = async () => {
setUpdateLoading(true);
try {
await RewardService.updateReward({
axiosInstance,
internalId: selectedReward!.internal_id,
data: selectedReward!,
});
toast.success("Reward updated successfully");
await refetch();
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to update coupon"));
}
setUpdateLoading(false);
};
if (!selectedReward) {
setOpen(false);
return;
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[500px]">
<DialogTitle>Update Reward</DialogTitle>
<WarningBox>
Existing customers with this coupon will not be affected
</WarningBox>
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,
},
);
}
}
{selectedReward && (
<RewardConfig reward={selectedReward} setReward={setSelectedReward} />
)}
await RewardService.updateReward({
axiosInstance,
internalId: selectedReward.internal_id,
data: selectedReward,
});
toast.success("Reward updated successfully");
await refetch();
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to update coupon"));
}
setUpdateLoading(false);
};
<DialogFooter>
<Button
isLoading={updateLoading}
onClick={() => handleUpdate()}
variant="gradientPrimary"
>
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[500px]">
<DialogTitle>Update Reward</DialogTitle>
<WarningBox>
Existing customers with this coupon will not be affected
</WarningBox>
{selectedReward && (
<RewardConfig reward={selectedReward} setReward={setSelectedReward} />
)}
<DialogFooter>
<Button
isLoading={updateLoading}
onClick={() => handleUpdate()}
variant="gradientPrimary"
>
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default UpdateReward;

View File

@@ -7,32 +7,32 @@ import {
} from "@autumn/shared";
export const defaultDiscountConfig: DiscountConfig = {
discount_value: 0,
duration_type: CouponDurationType.Months,
duration_value: 0,
should_rollover: true,
apply_to_all: true,
price_ids: [],
discount_value: 0,
duration_type: CouponDurationType.Months,
duration_value: 0,
should_rollover: true,
apply_to_all: true,
price_ids: [],
};
export const defaultFreeProductConfig: FreeProductConfig = {
duration_type: CouponDurationType.Months,
duration_value: 0,
duration_type: CouponDurationType.Months,
duration_value: 0,
};
export const defaultReward: CreateReward = {
name: "",
id: "",
promo_codes: [{ code: "" }],
name: "",
id: "",
promo_codes: [{ code: "" }],
type: RewardType.PercentageDiscount,
type: RewardType.PercentageDiscount,
// For free product coupons
free_product_id: null,
// For free product coupons
free_product_id: null,
// For discount type coupons
discount_config: defaultDiscountConfig,
// For discount type coupons
discount_config: defaultDiscountConfig,
// For free product type coupons
free_product_config: defaultFreeProductConfig,
// For free product type coupons
free_product_config: defaultFreeProductConfig,
};