Merge branch 'staging' of https://github.com/useautumn/autumn into staging

This commit is contained in:
John Yeo
2025-09-22 13:06:11 +01:00
25 changed files with 2142 additions and 1642 deletions

View File

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

View File

@@ -1,50 +1,52 @@
import { ClickHouseClient } from "@clickhouse/client"; /** biome-ignore-all lint/complexity/noStaticOnlyClass: wrap it up buddy */
import { ErrCode, FullCustomer } from "@autumn/shared";
import { ExtendedRequest } from "@/utils/models/Request.js"; import { ErrCode, type FullCustomer } from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js"; import type { ClickHouseClient } from "@clickhouse/client";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { import {
generateEventCountExpressions, generateEventCountExpressions,
getBillingCycleStartDate, getBillingCycleStartDate,
} from "./analyticsUtils.js"; } from "./analyticsUtils.js";
export class AnalyticsService { export class AnalyticsService {
static clickhouseAvailable = static clickhouseAvailable =
process.env.CLICKHOUSE_URL && process.env.CLICKHOUSE_URL &&
process.env.CLICKHOUSE_USERNAME && process.env.CLICKHOUSE_USERNAME &&
process.env.CLICKHOUSE_PASSWORD; process.env.CLICKHOUSE_PASSWORD;
static handleEarlyExit = () => { static handleEarlyExit = () => {
if (!AnalyticsService.clickhouseAvailable) { if (!AnalyticsService.clickhouseAvailable) {
throw new RecaseError({ throw new RecaseError({
message: "ClickHouse is disabled, cannot fetch events", message: "ClickHouse is disabled, cannot fetch events",
code: ErrCode.ClickHouseDisabled, code: ErrCode.ClickHouseDisabled,
statusCode: StatusCodes.SERVICE_UNAVAILABLE, statusCode: StatusCodes.SERVICE_UNAVAILABLE,
}); });
} }
}; };
static formatJsDateToClickHouseDateTime(date: Date) { static formatJsDateToClickHouseDateTime(date: Date) {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes() - 1).padStart(2, "0"); const minutes = String(date.getMinutes() - 1).padStart(2, "0");
const seconds = String(date.getSeconds() - 1).padStart(2, "0"); const seconds = String(date.getSeconds() - 1).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
} }
static async getTopEventNames({ static async getTopEventNames({
req, req,
limit = 3, limit = 3,
}: { }: {
req: ExtendedRequest; req: ExtendedRequest;
limit?: number; limit?: number;
}) { }) {
const { clickhouseClient, org, env } = req; const { clickhouseClient, org, env } = req;
const query = ` const query = `
select count(*) as count, event_name select count(*) as count, event_name
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String}) from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
where timestamp >= NOW() - INTERVAL '1 month' where timestamp >= NOW() - INTERVAL '1 month'
@@ -52,27 +54,27 @@ export class AnalyticsService {
order by count(*) desc order by count(*) desc
limit {limit:UInt32} limit {limit:UInt32}
`; `;
const result = await clickhouseClient.query({ const result = await clickhouseClient.query({
query, query,
query_params: { query_params: {
org_id: org?.id, org_id: org?.id,
env: env, env: env,
limit, limit,
}, },
}); });
const resultJson = await result.json(); const resultJson = await result.json();
return { return {
eventNames: resultJson.data.map((row: any) => row.event_name), eventNames: resultJson.data.map((row: any) => row.event_name),
result: resultJson, result: resultJson,
}; };
} }
static async getTopUser({ req }: { req: ExtendedRequest }) { static async getTopUser({ req }: { req: ExtendedRequest }) {
const { clickhouseClient, org, env, db } = req; const { clickhouseClient, org, env, db } = req;
const query = ` const query = `
SELECT SELECT
c.name c.name
FROM FROM
@@ -113,116 +115,118 @@ WHERE
) )
`; `;
const result = await clickhouseClient.query({ const result = await clickhouseClient.query({
query, query,
query_params: { query_params: {
org_id: org?.id, org_id: org?.id,
env: env, env: env,
}, },
}); });
const resultJson = await result.json(); const resultJson = await result.json();
return (resultJson.data as { name: string; count: number }[])[0]; return (resultJson.data as { name: string; count: number }[])[0];
} }
static async getTotalEvents({ static async getTotalEvents({
req, req,
eventName, eventName,
}: { }: {
req: ExtendedRequest; req: ExtendedRequest;
eventName?: string; eventName?: string;
}) { }) {
const { clickhouseClient, org, env, db } = req; const { clickhouseClient, org, env, db } = req;
const query = ` const query = `
SELECT org_id, env, COUNT(*) AS total_events SELECT SUM(
FROM events CASE
WHERE org_id = {org_id: String} WHEN JSONHas(properties, 'value') THEN toInt64(JSONExtractFloat(properties, 'value'))
AND env = {env: String} WHEN value IS NOT NULL THEN toInt64(value)
${eventName ? `AND event_name = {eventName: String}` : ""} ELSE 1
GROUP BY org_id, env END
LIMIT 1; ) AS total_events
FROM org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
WHERE event_name = {eventName:String}
`; `;
const result = await clickhouseClient.query({ const result = await clickhouseClient.query({
query, query,
query_params: { query_params: {
org_id: org?.id, org_id: org?.id,
env: env, env: env,
eventName: eventName ?? undefined, eventName: eventName ?? undefined,
}, },
}); });
const resultJson = await result.json(); const resultJson = await result.json();
return (resultJson.data as { total_events: number }[])[0].total_events; return (resultJson.data as { total_events: number }[])[0].total_events;
} }
static async getTotalCustomers({ req }: { req: ExtendedRequest }) { static async getTotalCustomers({ req }: { req: ExtendedRequest }) {
const { clickhouseClient, org, env, db } = req; const { clickhouseClient, org, env, db } = req;
const query = `SELECT COUNT(DISTINCT id) AS total_customers const query = `SELECT COUNT(DISTINCT id) AS total_customers
FROM customers FROM customers
WHERE org_id = {org_id:String} WHERE org_id = {org_id:String}
AND env = {env:String};`; AND env = {env:String};`;
const result = await clickhouseClient.query({ const result = await clickhouseClient.query({
query, query,
query_params: { query_params: {
org_id: org?.id, org_id: org?.id,
env: env, env: env,
}, },
}); });
const resultJson = await result.json(); const resultJson = await result.json();
return (resultJson.data as { total_customers: number }[])[0] return (resultJson.data as { total_customers: number }[])[0]
.total_customers; .total_customers;
} }
static async getTimeseriesEvents({ static async getTimeseriesEvents({
req, req,
params, params,
customer, customer,
aggregateAll = false, aggregateAll = false,
}: { }: {
req: ExtendedRequest; req: ExtendedRequest;
params: { params: {
event_names: string[]; event_names: string[];
interval: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc"; interval: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc";
customer_id?: string; customer_id?: string;
no_count?: boolean; no_count?: boolean;
}; };
customer?: FullCustomer; customer?: FullCustomer;
aggregateAll?: boolean; aggregateAll?: boolean;
}) { }) {
const { clickhouseClient, org, env, db } = req; const { clickhouseClient, org, env, db } = req;
const intervalType: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc" = const intervalType: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc" =
params.interval || "24h"; params.interval || "24h";
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc"; const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
AnalyticsService.handleEarlyExit(); AnalyticsService.handleEarlyExit();
// Skip billing cycle calculation if aggregating all customers // Skip billing cycle calculation if aggregating all customers
let getBCResults = const getBCResults =
isBillingCycle && !aggregateAll && customer isBillingCycle && !aggregateAll && customer
? ((await getBillingCycleStartDate( ? ((await getBillingCycleStartDate(
env, env,
org?.id, org?.id,
customer, customer,
db, db,
intervalType as "1bc" | "3bc" intervalType as "1bc" | "3bc",
)) as { startDate: string; endDate: string; gap: number } | null) )) as { startDate: string; endDate: string; gap: number } | null)
: null; : null;
const countExpressions = generateEventCountExpressions( const countExpressions = generateEventCountExpressions(
params.event_names, params.event_names,
params.no_count params.no_count,
); );
if (AnalyticsService.clickhouseAvailable) { if (AnalyticsService.clickhouseAvailable) {
const query = ` const query = `
with customer_events as ( with customer_events as (
select * select *
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String}) from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
@@ -238,7 +242,7 @@ group by dr.period
order by dr.period; order by dr.period;
`; `;
const queryBillingCycle = ` const queryBillingCycle = `
with customer_events as ( with customer_events as (
select * select *
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String}) from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
@@ -254,118 +258,118 @@ group by dr.period
order by dr.period; order by dr.period;
`; `;
const queryParams = { const queryParams = {
org_id: org?.id, org_id: org?.id,
env: env, env: env,
customer_id: params.customer_id, customer_id: params.customer_id,
days: days:
intervalType === "24h" intervalType === "24h"
? 1 ? 1
: intervalType === "7d" : intervalType === "7d"
? 7 ? 7
: intervalType === "30d" : intervalType === "30d"
? 30 ? 30
: intervalType === "90d" : intervalType === "90d"
? 90 ? 90
: intervalType === "1bc" : intervalType === "1bc"
? (getBCResults?.gap ?? 0) + 1 ? (getBCResults?.gap ?? 0) + 1
: intervalType === "3bc" : intervalType === "3bc"
? (getBCResults?.gap ?? 0) ? (getBCResults?.gap ?? 0)
: 0, : 0,
bin_size: intervalType === "24h" ? "hour" : "day", bin_size: intervalType === "24h" ? "hour" : "day",
end_date: isBillingCycle ? getBCResults?.endDate : undefined, end_date: isBillingCycle ? getBCResults?.endDate : undefined,
}; };
// Use regular query for aggregateAll or when no billing cycle data is available // Use regular query for aggregateAll or when no billing cycle data is available
const queryToUse = const queryToUse =
isBillingCycle && !aggregateAll && getBCResults?.startDate isBillingCycle && !aggregateAll && getBCResults?.startDate
? queryBillingCycle ? queryBillingCycle
: query; : query;
const result = await (clickhouseClient as ClickHouseClient).query({ const result = await (clickhouseClient as ClickHouseClient).query({
query: queryToUse, query: queryToUse,
query_params: queryParams, query_params: queryParams,
format: "JSON", format: "JSON",
clickhouse_settings: { clickhouse_settings: {
output_format_json_quote_decimals: 0, output_format_json_quote_decimals: 0,
output_format_json_quote_64bit_integers: 1, output_format_json_quote_64bit_integers: 1,
output_format_json_quote_64bit_floats: 1, output_format_json_quote_64bit_floats: 1,
}, },
}); });
let resultJson = await result.json(); const resultJson = await result.json();
resultJson.data.forEach((row: any) => { resultJson.data.forEach((row: any) => {
Object.keys(row).forEach((key: string) => { Object.keys(row).forEach((key: string) => {
if (key !== "period") { if (key !== "period") {
row[key] = parseInt(row[key]); row[key] = parseInt(row[key]);
} }
}); });
}); });
return resultJson; return resultJson;
} }
} }
static async getRawEvents({ static async getRawEvents({
req, req,
params, params,
customer, customer,
aggregateAll = false, aggregateAll = false,
}: { }: {
req: ExtendedRequest; req: ExtendedRequest;
params: any; params: any;
customer?: FullCustomer; customer?: FullCustomer;
aggregateAll?: boolean; aggregateAll?: boolean;
}) { }) {
const { clickhouseClient, org, db, env } = req; const { clickhouseClient, org, db, env } = req;
AnalyticsService.handleEarlyExit(); AnalyticsService.handleEarlyExit();
let startDate = new Date(); const startDate = new Date();
const intervalType = params.interval || "day"; const intervalType = params.interval || "day";
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc"; const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
// Skip billing cycle calculation if aggregating all customers // Skip billing cycle calculation if aggregating all customers
let getBCResults = const getBCResults =
isBillingCycle && !aggregateAll && customer isBillingCycle && !aggregateAll && customer
? ((await getBillingCycleStartDate( ? ((await getBillingCycleStartDate(
env, env,
org?.id, org?.id,
customer, customer,
db, db,
intervalType as "1bc" | "3bc" intervalType as "1bc" | "3bc",
)) as { startDate: string; endDate: string; gap: number } | null) )) as { startDate: string; endDate: string; gap: number } | null)
: null; : null;
switch (intervalType) { switch (intervalType) {
case "24h": case "24h":
startDate.setHours(startDate.getHours() - 24); startDate.setHours(startDate.getHours() - 24);
break; break;
case "7d": case "7d":
startDate.setDate(startDate.getDate() - 7); startDate.setDate(startDate.getDate() - 7);
break; break;
case "30d": case "30d":
startDate.setDate(startDate.getDate() - 30); startDate.setDate(startDate.getDate() - 30);
break; break;
case "90d": case "90d":
startDate.setDate(startDate.getDate() - 90); startDate.setDate(startDate.getDate() - 90);
break; break;
default: default:
startDate.setDate(startDate.getDate() - 24); startDate.setDate(startDate.getDate() - 24);
break; break;
} }
const finalStartDate = const finalStartDate =
isBillingCycle && getBCResults?.startDate isBillingCycle && getBCResults?.startDate
? getBCResults.startDate ? getBCResults.startDate
: AnalyticsService.formatJsDateToClickHouseDateTime(startDate); : AnalyticsService.formatJsDateToClickHouseDateTime(startDate);
const finalEndDate = const finalEndDate =
isBillingCycle && getBCResults?.endDate isBillingCycle && getBCResults?.endDate
? getBCResults.endDate ? getBCResults.endDate
: AnalyticsService.formatJsDateToClickHouseDateTime(new Date()); : AnalyticsService.formatJsDateToClickHouseDateTime(new Date());
const query = ` const query = `
SELECT * SELECT *
FROM org_events_view(org_id={organizationId:String}, org_slug='', env={env:String}) FROM org_events_view(org_id={organizationId:String}, org_slug='', env={env:String})
WHERE timestamp >= toDateTime({startDate:String}) WHERE timestamp >= toDateTime({startDate:String})
@@ -375,49 +379,49 @@ order by dr.period;
limit 10000 limit 10000
`; `;
const filledQuery = query const filledQuery = query
.replace("{organizationId:String}", org?.id ?? "") .replace("{organizationId:String}", org?.id ?? "")
.replace("{customerId:String}", params.customer_id ?? "") .replace("{customerId:String}", params.customer_id ?? "")
.replace("{startDate:String}", finalStartDate) .replace("{startDate:String}", finalStartDate)
.replace("{endDate:String}", finalEndDate) .replace("{endDate:String}", finalEndDate)
.replace("{env:String}", env); .replace("{env:String}", env);
// console.log("filledQuery", filledQuery); // console.log("filledQuery", filledQuery);
const result = await clickhouseClient.query({ const result = await clickhouseClient.query({
query: query, query: query,
query_params: { query_params: {
organizationId: org?.id, organizationId: org?.id,
customerId: params.customer_id, customerId: params.customer_id,
startDate: finalStartDate, startDate: finalStartDate,
endDate: finalEndDate, endDate: finalEndDate,
env: env, env: env,
}, },
}); });
// log the actual query... with params filled in...? // log the actual query... with params filled in...?
// console.log("query", query); // console.log("query", query);
const resultJson = await result.json(); const resultJson = await result.json();
return resultJson; return resultJson;
} }
// private static async getSubscriptionsIfNeeded( // private static async getSubscriptionsIfNeeded(
// customer: FullCustomer, // customer: FullCustomer,
// customerHasSubscriptions: boolean, // customerHasSubscriptions: boolean,
// db: DrizzleCli // db: DrizzleCli
// ): Promise<Subscription[]> { // ): Promise<Subscription[]> {
// if (customerHasSubscriptions) { // if (customerHasSubscriptions) {
// return []; // return [];
// } // }
// return await SubService.getInStripeIds({ // return await SubService.getInStripeIds({
// db, // db,
// ids: // ids:
// customer.customer_products?.flatMap( // customer.customer_products?.flatMap(
// (product: FullCusProduct) => product.subscription_ids ?? [] // (product: FullCusProduct) => product.subscription_ids ?? []
// ) ?? [], // ) ?? [],
// }); // });
// } // }
} }

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

View File

@@ -1,28 +1,28 @@
import RecaseError from "@/utils/errorUtils.js"; import { ErrCode, type FullProduct, UpdateProductSchema } from "@autumn/shared";
import { ErrCode, FullProduct, UpdateProductSchema } from "@autumn/shared"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { ProductService } from "../../ProductService.js";
import { notNullish } from "@/utils/genUtils.js";
import { FeatureService } from "@/internal/features/FeatureService.js"; import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js"; import { OrgService } from "@/internal/orgs/OrgService.js";
import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.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 { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.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 { 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 { productsAreSame } from "../../productUtils/compareProductUtils.js";
import { initProductInStripe } from "../../productUtils.js"; import { initProductInStripe } from "../../productUtils.js";
import { mapToProductItems } from "../../productV2Utils.js";
import { import {
disableCurrentDefault, disableCurrentDefault,
handleCreateProduct, handleCreateProduct,
} from "../handleCreateProduct.js"; } from "../handleCreateProduct.js";
import { mapToProductItems } from "../../productV2Utils.js"; import { handleVersionProductV2 } from "../handleVersionProduct.js";
import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js"; import { handleUpdateProductDetails } from "./updateProductDetails.js";
import { formatPrice } from "../../prices/priceUtils.js";
export const handleUpdateProductV2 = async (req: any, res: any) => export const handleUpdateProductV2 = async (req: any, res: any) =>
routeHandler({ routeHandler({
@@ -34,7 +34,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
const { version, upsert, disable_version } = req.query; const { version, upsert, disable_version } = req.query;
const { orgId, env, logger, db } = req; const { orgId, env, logger, db } = req;
const [features, org, fullProduct, rewardPrograms, defaultProds] = const [features, org, fullProduct, rewardPrograms, _defaultProds] =
await Promise.all([ await Promise.all([
FeatureService.getFromReq(req), FeatureService.getFromReq(req),
OrgService.getFromReq(req), OrgService.getFromReq(req),
@@ -44,7 +44,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
orgId, orgId,
env, env,
version: version ? parseInt(version) : undefined, version: version ? parseInt(version) : undefined,
allowNotFound: upsert == "true", allowNotFound: upsert === "true",
}), }),
RewardProgramService.getByProductId({ RewardProgramService.getByProductId({
db, db,
@@ -60,7 +60,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
]); ]);
if (!fullProduct) { if (!fullProduct) {
if (upsert == "true") { if (upsert === "true") {
await handleCreateProduct(req, res); await handleCreateProduct(req, res);
return; return;
} }
@@ -78,7 +78,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
internalProductId: fullProduct.internal_id, internalProductId: fullProduct.internal_id,
}); });
let cusProductExists = cusProductsCurVersion.length > 0; const cusProductExists = cusProductsCurVersion.length > 0;
// console.log("Updating product", { // console.log("Updating product", {
// id: fullProduct.id, // id: fullProduct.id,
@@ -111,15 +111,14 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
logger, logger,
}); });
let itemsExist = notNullish(req.body.items); const itemsExist = notNullish(req.body.items);
if (cusProductExists && itemsExist) { if (cusProductExists && itemsExist) {
if (disable_version == "true") { if (disable_version === "true") {
throw new RecaseError({ throw new RecaseError({
message: "Cannot auto save product as there are existing customers", message: "Cannot auto save product as there are existing customers",
code: ErrCode.InvalidRequest, code: ErrCode.InvalidRequest,
statusCode: 400, statusCode: 400,
}); });
return;
} }
const { itemsSame, freeTrialsSame } = productsAreSame({ const { itemsSame, freeTrialsSame } = productsAreSame({
@@ -154,7 +153,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
}); });
} }
const { prices, entitlements } = await handleNewProductItems({ await handleNewProductItems({
db, db,
curPrices: fullProduct.prices, curPrices: fullProduct.prices,
curEnts: fullProduct.entitlements, curEnts: fullProduct.entitlements,
@@ -165,9 +164,17 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
isCustom: false, isCustom: false,
}); });
// New full product
const newFullProduct = await ProductService.getFull({
db,
idOrInternalId: fullProduct.id,
orgId,
env,
});
if (free_trial !== undefined) { if (free_trial !== undefined) {
await validateOneOffTrial({ await validateOneOffTrial({
prices, prices: newFullProduct.prices,
freeTrial: free_trial, freeTrial: free_trial,
}); });
@@ -181,13 +188,10 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
}); });
} }
// New full product
await initProductInStripe({ await initProductInStripe({
db, db,
product: { product: newFullProduct,
...fullProduct,
prices,
entitlements,
} as FullProduct,
org, org,
env, env,
logger, logger,
@@ -197,14 +201,19 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
await addTaskToQueue({ await addTaskToQueue({
jobName: JobName.DetectBaseVariant, jobName: JobName.DetectBaseVariant,
payload: { payload: {
curProduct: { curProduct: newFullProduct,
...fullProduct,
prices: prices.length > 0 ? prices : fullProduct.prices,
entitlements,
},
}, },
}); });
await addTaskToQueue({
jobName: JobName.RewardMigration,
payload: {
oldPrices: fullProduct.prices,
productId: fullProduct.id,
orgId: org.id,
env,
},
});
res.status(200).send({ message: "Product updated" }); res.status(200).send({ message: "Product updated" });
return; 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 { FeatureService } from "@/internal/features/FeatureService.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js"; import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js";
import { ProductService } from "@/internal/products/ProductService.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 { import {
constructProduct, constructProduct,
initProductInStripe, initProductInStripe,
} from "@/internal/products/productUtils.js"; } 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 { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { getEntsWithFeature } from "../entitlements/entitlementUtils.js"; import { getEntsWithFeature } from "../entitlements/entitlementUtils.js";
export const handleVersionProductV2 = async ({ export const handleVersionProductV2 = async ({
req, req,
res, res,
latestProduct, latestProduct,
org, org,
env, env,
items, items,
freeTrial, freeTrial,
}: { }: {
req: any; req: any;
res: any; res: any;
latestProduct: FullProduct; latestProduct: FullProduct;
org: Organization; org: Organization;
env: AppEnv; env: AppEnv;
items: ProductItem[]; items: ProductItem[];
freeTrial: FreeTrial; freeTrial: FreeTrial;
}) => { }) => {
const { db } = req; const { db } = req;
let curVersion = latestProduct.version; const curVersion = latestProduct.version;
let newVersion = curVersion + 1; const newVersion = curVersion + 1;
let features = await FeatureService.getFromReq(req); const features = await FeatureService.getFromReq(req);
console.log( console.log(
`Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}` `Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}`,
); );
const newProduct = constructProduct({ const newProduct = constructProduct({
productData: CreateProductSchema.parse({ productData: CreateProductSchema.parse({
...latestProduct, ...latestProduct,
...req.body, ...req.body,
version: newVersion, version: newVersion,
}), }),
orgId: org.id, orgId: org.id,
env: latestProduct.env as AppEnv, env: latestProduct.env as AppEnv,
processor: latestProduct.processor, processor: latestProduct.processor,
baseVariantId: latestProduct.base_variant_id, baseVariantId: latestProduct.base_variant_id,
}); });
// Validate product items... // Validate product items...
validateProductItems({ validateProductItems({
newItems: items, newItems: items,
features, features,
orgId: org.id, orgId: org.id,
env, env,
}); });
if (latestProduct.is_default) { if (latestProduct.is_default) {
await ProductService.updateByInternalId({ await ProductService.updateByInternalId({
db, db,
internalId: latestProduct.internal_id, internalId: latestProduct.internal_id,
update: { update: {
is_default: false, is_default: false,
}, },
}); });
} }
await ProductService.insert({ db, product: newProduct }); await ProductService.insert({ db, product: newProduct });
const { customPrices, customEnts } = await handleNewProductItems({ const { customPrices, customEnts } = await handleNewProductItems({
db, db,
curPrices: latestProduct.prices, curPrices: latestProduct.prices,
curEnts: latestProduct.entitlements, curEnts: latestProduct.entitlements,
newItems: items, newItems: items,
features, features,
product: newProduct, product: newProduct,
logger: console, logger: console,
isCustom: false, isCustom: false,
newVersion: true, newVersion: true,
}); });
await EntitlementService.insert({ await EntitlementService.insert({
db, db,
data: customEnts, data: customEnts,
}); });
await PriceService.insert({ await PriceService.insert({
db, db,
data: customPrices, data: customPrices,
}); });
// Handle new free trial // Handle new free trial
if (freeTrial) { if (freeTrial) {
await handleNewFreeTrial({ await handleNewFreeTrial({
db, db,
newFreeTrial: freeTrial, newFreeTrial: freeTrial,
curFreeTrial: null, curFreeTrial: null,
internalProductId: newProduct.internal_id, internalProductId: newProduct.internal_id,
isCustom: false, isCustom: false,
}); });
} }
// await addTaskToQueue({ // await addTaskToQueue({
// jobName: JobName.DetectBaseVariant, // jobName: JobName.DetectBaseVariant,
// payload: { // payload: {
// curProduct: { // curProduct: {
// ...newProduct, // ...newProduct,
// // prices: customPrices, // // prices: customPrices,
// // entitlements: getEntsWithFeature({ ents: customEnts, features }), // // entitlements: getEntsWithFeature({ ents: customEnts, features }),
// }, // },
// }, // },
// }); // });
await initProductInStripe({ await initProductInStripe({
db, db,
product: { product: {
...newProduct, ...newProduct,
prices: customPrices, prices: customPrices,
entitlements: getEntsWithFeature({ ents: customEnts, features }), entitlements: getEntsWithFeature({ ents: customEnts, features }),
} as FullProduct, } as FullProduct,
org, org,
env, env,
logger: console, 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 { mapToProductV2 } from "./productV2Utils.js";
import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js"; import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js";
import RecaseError, { import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
handleFrontendReqError,
handleRequestError,
} from "@/utils/errorUtils.js";
import { createOrgResponse } from "../orgs/orgUtils.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"; import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js";
export const productRouter: Router = Router({ mergeParams: true }); export const productRouter: Router = Router({ mergeParams: true });
@@ -35,6 +35,8 @@ productRouter.get("/products", async (req: any, res) => {
env: req.env, env: req.env,
}); });
sortFullProducts({ products });
const groupToDefaults = getGroupToDefaults({ const groupToDefaults = getGroupToDefaults({
defaultProds: products, defaultProds: products,
}); });

View File

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

View File

@@ -1,3 +1,4 @@
import RecaseError from "@/utils/errorUtils.js";
import { import {
AllowanceType, AllowanceType,
BillingInterval, BillingInterval,
@@ -19,13 +20,12 @@ import {
OnIncrease, OnIncrease,
OnDecrease, OnDecrease,
FeatureUsageType, FeatureUsageType,
features,
} from "@autumn/shared"; } from "@autumn/shared";
import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
import { pricesAreSame } from "@/internal/products/prices/priceInitUtils.js"; import { pricesAreSame } from "@/internal/products/prices/priceInitUtils.js";
import { entsAreSame } from "../../entitlements/entitlementUtils.js"; import { entsAreSame } from "../../entitlements/entitlementUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js"; import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { import {
isFeatureItem, isFeatureItem,
isFeaturePriceItem, 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 { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js"; import RecaseError from "@/utils/errorUtils.js";
export class RewardService { export class RewardService {
static async get({ static async get({
db, db,
idOrInternalId, idOrInternalId,
orgId, orgId,
env, env,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
idOrInternalId: string; idOrInternalId: string;
orgId: string; orgId: string;
env: AppEnv; env: AppEnv;
}) { }) {
const result = await db.query.rewards.findFirst({ const result = await db.query.rewards.findFirst({
where: and( where: and(
or( or(
eq(rewards.id, idOrInternalId), eq(rewards.id, idOrInternalId),
eq(rewards.internal_id, idOrInternalId), eq(rewards.internal_id, idOrInternalId)
), ),
eq(rewards.org_id, orgId), eq(rewards.org_id, orgId),
eq(rewards.env, env), eq(rewards.env, env)
), ),
}); });
if (!result) { if (!result) {
return null; return null;
} }
return result as Reward; return result as Reward;
} }
static async getByIdOrCode({ static async getByIdOrCode({
db, db,
codes, codes,
orgId, orgId,
env, env,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
codes: string[]; codes: string[];
orgId: string; orgId: string;
env: AppEnv; env: AppEnv;
}) { }) {
const reward = await db.query.rewards.findMany({ const reward = await db.query.rewards.findMany({
where: and( where: and(
eq(rewards.org_id, orgId), eq(rewards.org_id, orgId),
eq(rewards.env, env), eq(rewards.env, env),
or( or(
inArray(rewards.id, codes), inArray(rewards.id, codes),
...codes.map( ...codes.map(
(code) => sql`EXISTS ( (code) => sql`EXISTS (
SELECT 1 FROM unnest("promo_codes") AS elem SELECT 1 FROM unnest("promo_codes") AS elem
WHERE elem->>'code' = ${code} WHERE elem->>'code' = ${code}
)`, )`
), )
), )
), ),
}); });
return reward as Reward[]; return reward as Reward[];
} }
static async insert({ static async insert({
db, db,
data, data,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
data: Reward | Reward[]; data: Reward | Reward[];
}) { }) {
const results = await db.insert(rewards).values(data as Reward); const results = await db.insert(rewards).values(data as Reward);
return results as Reward[]; return results as Reward[];
} }
static async list({ static async list({
db, db,
orgId, orgId,
env, env,
}: { inTypes,
db: DrizzleCli; }: {
orgId: string; db: DrizzleCli;
env: AppEnv; orgId: string;
}) { env: AppEnv;
const results = await db.query.rewards.findMany({ inTypes?: RewardType[];
where: and(eq(rewards.org_id, orgId), eq(rewards.env, env)), }) {
orderBy: [desc(rewards.internal_id)], 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({ static async delete({
db, db,
internalId, internalId,
env, env,
orgId, orgId,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
internalId: string; internalId: string;
env: AppEnv; env: AppEnv;
orgId: string; orgId: string;
}) { }) {
await db await db
.delete(rewards) .delete(rewards)
.where( .where(
and( and(
eq(rewards.internal_id, internalId), eq(rewards.internal_id, internalId),
eq(rewards.env, env), eq(rewards.env, env),
eq(rewards.org_id, orgId), eq(rewards.org_id, orgId)
), )
); );
} }
static async update({ static async update({
db, db,
internalId, internalId,
env, env,
orgId, orgId,
update, update,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
internalId: string; internalId: string;
env: AppEnv; env: AppEnv;
orgId: string; orgId: string;
update: Partial<Reward>; update: Partial<Reward>;
}) { }) {
const result = await db const result = await db
.update(rewards) .update(rewards)
.set(update) .set(update)
.where( .where(
and( and(
eq(rewards.internal_id, internalId), eq(rewards.internal_id, internalId),
eq(rewards.env, env), eq(rewards.env, env),
eq(rewards.org_id, orgId), eq(rewards.org_id, orgId)
), )
) )
.returning(); .returning();
if (result.length === 0) { if (result.length === 0) {
throw new RecaseError({ throw new RecaseError({
message: `Reward ${internalId} not found`, message: `Reward ${internalId} not found`,
code: ErrCode.InvalidRequest, code: ErrCode.InvalidRequest,
}); });
} }
return result[0] as Reward; return result[0] as Reward;
} }
static async deleteByOrgId({ static async deleteByOrgId({
db, db,
orgId, orgId,
env, env,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
orgId: string; orgId: string;
env: AppEnv; env: AppEnv;
}) { }) {
await db await db
.delete(rewards) .delete(rewards)
.where(and(eq(rewards.org_id, orgId), eq(rewards.env, env))); .where(and(eq(rewards.org_id, orgId), eq(rewards.env, env)));
} }
} }

View File

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

View File

@@ -4,153 +4,129 @@ export { schemas };
// Auth Models // Auth Models
export * from "./db/auth-schema.js"; 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"; export * from "./models/authModels/membership.js";
export * from "./models/chatResultModels/chatResultFeature.js";
// Gen Models export * from "./models/chatResultModels/chatResultFeature.js";
export * from "./models/genModels/genEnums.js"; // 4. Chat Result Models
export * from "./models/chatResultModels/chatResultTable.js";
// 1. Org Models export * from "./models/checkModels/checkPreviewModels.js";
export * from "./models/orgModels/orgTable.js"; export * from "./models/cusModels/cusExpand.js";
export * from "./models/orgModels/orgConfig.js"; // 8. Customer Models
export * from "./models/orgModels/frontendOrg.js"; export * from "./models/cusModels/cusModels.js";
export * from "./models/cusModels/cusResModels/cusFeatureResponse.js";
// 2. Feature Models export * from "./models/cusModels/cusResModels/cusProductResponse.js";
export * from "./models/featureModels/featureTable.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/featureEnums.js";
export * from "./models/featureModels/featureModels.js"; export * from "./models/featureModels/featureModels.js";
export * from "./models/featureModels/featureResModels.js"; export * from "./models/featureModels/featureResModels.js";
export * from "./models/featureModels/featureConfig/meteredConfig.js"; // 2. Feature Models
export * from "./models/featureModels/featureConfig/creditConfig.js"; 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 // 3. Entitlement Models
export * from "./models/productModels/entModels/entTable.js"; 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 // 4. Free Trial Models
export * from "./models/productModels/freeTrialModels/freeTrialEnums.js"; export * from "./models/productModels/freeTrialModels/freeTrialEnums.js";
export * from "./models/productModels/freeTrialModels/freeTrialModels.js"; export * from "./models/productModels/freeTrialModels/freeTrialModels.js";
export * from "./models/productModels/freeTrialModels/freeTrialTable.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/fixedPriceConfig.js";
export * from "./models/productModels/priceModels/priceConfig/usagePriceConfig.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/priceModels.js";
export * from "./models/productModels/priceModels/priceTable.js";
// 5. Product Models // 5. Product Models
export * from "./models/productModels/productEnums.js"; export * from "./models/productModels/productEnums.js";
export * from "./models/productModels/productTable.js";
export * from "./models/productModels/productModels.js"; export * from "./models/productModels/productModels.js";
export * from "./models/productModels/productRelations.js"; export * from "./models/productModels/productRelations.js";
export * from "./models/productModels/productTable.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/productV2Models/productItemModels/featureItem.js"; export * from "./models/productV2Models/productItemModels/featureItem.js";
export * from "./models/productV2Models/productItemModels/featurePriceItem.js"; export * from "./models/productV2Models/productItemModels/featurePriceItem.js";
export * from "./models/productV2Models/productItemModels/priceItem.js"; export * from "./models/productV2Models/productItemModels/priceItem.js";
export * from "./models/productV2Models/productItemModels/prodItemResponseModels.js";
export * from "./models/productV2Models/productItemModels/productItemEnums.js"; export * from "./models/productV2Models/productItemModels/productItemEnums.js";
export * from "./models/productV2Models/productItemModels/productItemModels.js";
// 7. Cus Product Models export * from "./models/productV2Models/productResponseModels.js";
export * from "./models/cusProductModels/cusProductModels.js"; // 6. Product V2 Models
export * from "./models/cusProductModels/cusProductTable.js"; export * from "./models/productV2Models/productV2Models.js";
export * from "./models/cusProductModels/cusProductEnums.js"; export * from "./models/rewardModels/referralModels/referralCodeTable.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/rewardModels/referralModels/referralModels.js"; export * from "./models/rewardModels/referralModels/referralModels.js";
export * from "./models/rewardModels/referralModels/rewardRedemptionTable.js"; export * from "./models/rewardModels/referralModels/rewardRedemptionTable.js";
export * from "./models/rewardModels/referralModels/referralCodeTable.js"; export * from "./models/rewardModels/rewardModels/rewardEnums.js";
// Reward Models
// 5. Others: events, apiKeys export * from "./models/rewardModels/rewardModels/rewardModels.js";
export * from "./models/eventModels/eventModels.js"; export * from "./models/rewardModels/rewardModels/rewardResponseModels.js";
export * from "./models/eventModels/eventTable.js"; export * from "./models/rewardModels/rewardModels/rewardTable.js";
export * from "./models/rewardModels/rewardProgramModels/rewardProgramEnums.js";
export * from "./models/devModels/apiKeyModels.js"; export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js";
export * from "./models/devModels/apiKeyTable.js"; export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js";
export * from "./models/otherModels/metadataModels.js";
export * from "./models/otherModels/metadataTable.js";
export * from "./models/subModels/subModels.js"; export * from "./models/subModels/subModels.js";
export * from "./models/subModels/subTable.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 // Utils
export * from "./utils/displayUtils.js"; 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"; export * from "./utils/index.js";
export * from "./utils/intervalUtils.js";
// ENUMS export * from "./utils/productDisplayUtils/getProductItemRes.js";
export * from "./enums/SuccessCode.js"; export * from "./utils/productDisplayUtils/sortProductItems.js";
export * from "./enums/ErrCode.js"; export * from "./utils/productUtils/priceToInvoiceAmount.js";
export * from "./enums/LoggerAction.js"; export * from "./utils/productUtils.js";
export * from "./enums/AttachErrCode.js"; export * from "./utils/rewardUtils/rewardMigrationUtils.js";
export * from "./enums/APIVersion.js";
export * from "./enums/WebhookEventType.js";

View File

@@ -13,6 +13,8 @@ export const UsageTierSchema = z.object({
amount: z.number(), amount: z.number(),
}); });
export type UsageTier = z.infer<typeof UsageTierSchema>;
export const UsagePriceConfigSchema = z.object({ export const UsagePriceConfigSchema = z.object({
type: z.string(), type: z.string(),
bill_when: z.nativeEnum(BillWhen), 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 type { Feature } from "@autumn/shared";
import { Feature } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useFeaturesQuery = () => { export const useFeaturesQuery = () => {
const axiosInstance = useAxiosInstance(); const axiosInstance = useAxiosInstance();
const fetchFeatures = async () => { const fetchFeatures = async () => {
const { data } = await axiosInstance.get("/products/features"); const { data } = await axiosInstance.get("/products/features");
return data; return data;
}; };
const { data, isLoading, error, refetch } = useQuery<{ const { data, isLoading, error, refetch } = useQuery<{
features: Feature[]; features: Feature[];
}>({ }>({
queryKey: ["features"], queryKey: ["features"],
queryFn: fetchFeatures, 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 type { FullProduct, ProductCounts, ProductV2 } from "@autumn/shared";
import { FullProduct, ProductCounts, ProductV2 } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useProductsQuery = () => { export const useProductsQuery = () => {
const axiosInstance = useAxiosInstance(); const axiosInstance = useAxiosInstance();
const fetchProducts = async () => { const fetchProducts = async () => {
const { data } = await axiosInstance.get("/products/products"); const { data } = await axiosInstance.get("/products/products");
return data; return data;
}; };
const fetchProductCounts = async () => { const fetchProductCounts = async () => {
const { data } = await axiosInstance.get("/products/product_counts"); const { data } = await axiosInstance.get("/products/product_counts");
return data; return data;
}; };
const { data, isLoading, error, refetch } = useQuery<{ const { data, isLoading, error, refetch } = useQuery<{
products: ProductV2[]; products: ProductV2[];
groupToDefaults: Record<string, Record<string, FullProduct>>; groupToDefaults: Record<string, Record<string, FullProduct>>;
}>({ }>({
queryKey: ["products"], queryKey: ["products"],
queryFn: fetchProducts, queryFn: fetchProducts,
}); });
const { data: countsData, refetch: countsRefetch } = useQuery< const { data: countsData, refetch: countsRefetch } = useQuery<
Record<string, ProductCounts> Record<string, ProductCounts>
>({ >({
queryKey: ["product_counts"], queryKey: ["product_counts"],
queryFn: fetchProductCounts, queryFn: fetchProductCounts,
}); });
return { return {
products: data?.products || [], products: (data?.products || []) as ProductV2[],
counts: countsData || {}, counts: countsData || {},
groupToDefaults: data?.groupToDefaults || {}, groupToDefaults: data?.groupToDefaults || {},
isLoading, isLoading,
error, error,
refetch: async () => { refetch: async () => {
await Promise.all([countsRefetch(), refetch()]); await Promise.all([countsRefetch(), refetch()]);
}, },
// mutate: async () => { // mutate: async () => {
// await Promise.all([countsRefetch(), refetch()]); // 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 { useQuery } from "@tanstack/react-query";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useRewardsQuery = () => { export const useRewardsQuery = () => {
const axiosInstance = useAxiosInstance(); const axiosInstance = useAxiosInstance();
const fetchRewards = async () => { const fetchRewards = async () => {
const { data } = await axiosInstance.get("/products/rewards"); const { data } = await axiosInstance.get("/products/rewards");
return data; return data;
}; };
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ["rewards"], queryKey: ["rewards"],
queryFn: fetchRewards, queryFn: fetchRewards,
}); });
return { return {
rewards: data?.rewards || [], rewards: (data?.rewards || []) as Reward[],
rewardPrograms: data?.rewardPrograms || [], rewardPrograms: (data?.rewardPrograms || []) as RewardProgram[],
isLoading, isLoading,
error, error,
refetch, refetch,
}; };
}; };

View File

@@ -75,14 +75,18 @@ function CreateCustomer() {
</DialogHeader> </DialogHeader>
<div className="flex gap-2"> <div className="flex gap-2">
<div> <div>
<FieldLabel>Name</FieldLabel> <FieldLabel>
Name
</FieldLabel>
<Input <Input
value={fields.name} value={fields.name}
onChange={(e) => setFields({ ...fields, name: e.target.value })} onChange={(e) => setFields({ ...fields, name: e.target.value })}
/> />
</div> </div>
<div> <div>
<FieldLabel>ID</FieldLabel> <FieldLabel>
ID
</FieldLabel>
<Input <Input
value={fields.id} value={fields.id}
onChange={(e) => setFields({ ...fields, id: e.target.value })} onChange={(e) => setFields({ ...fields, id: e.target.value })}
@@ -111,6 +115,7 @@ function CreateCustomer() {
onClick={handleCreate} onClick={handleCreate}
isLoading={isLoading} isLoading={isLoading}
variant="gradientPrimary" variant="gradientPrimary"
disabled={!fields.id.trim() && !fields.email.trim()} // ✅ at least one of id or email
> >
Create Create
</Button> </Button>

View File

@@ -1,195 +1,213 @@
import type { AgChartOptions, FormatterParams } from "ag-charts-community";
import { AgCharts } from "ag-charts-react";
import { import {
AllCommunityModule, AllCommunityModule,
ColDef, type ColDef,
ModuleRegistry, ModuleRegistry,
type PaginationChangedEvent,
type RowDataUpdatedEvent,
ValidationModule, ValidationModule,
ValueFormatterParams, type ValueFormatterParams,
RowDataUpdatedEvent,
PaginationChangedEvent,
} from "ag-grid-community"; } from "ag-grid-community";
import { AgChartOptions, FormatterParams } from "ag-charts-community";
import { AgCharts } from "ag-charts-react";
// Register all Community features // Register all Community features
import { AgGridReact } from "ag-grid-react"; import { AgGridReact } from "ag-grid-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IRow, Row, autumnTheme, paginationOptions } from "./components/AGGrid";
import { useAnalyticsContext } from "./AnalyticsContext"; import { useAnalyticsContext } from "./AnalyticsContext";
import {
autumnTheme,
type IRow,
paginationOptions,
type Row,
} from "./components/AGGrid";
import { RowClickDialog } from "./components/RowClickDialog"; import { RowClickDialog } from "./components/RowClickDialog";
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Helper function to parse UTC timestamps from the backend
const parseUTCTimestamp = (timestamp: string): Date => {
// If the timestamp doesn't end with 'Z' or have timezone info, assume it's UTC
if (!timestamp.includes('Z') && !timestamp.includes('+') && !timestamp.includes('-', 10)) {
// Add 'Z' to indicate UTC if it's missing
return new Date(timestamp + (timestamp.includes('T') ? 'Z' : ' UTC'));
}
return new Date(timestamp);
};
const dateFormatter = new Intl.DateTimeFormat(navigator.language || "en-US", { const dateFormatter = new Intl.DateTimeFormat(navigator.language || "en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
timeZone: userTimeZone,
}); });
const hourFormatter = new Intl.DateTimeFormat(navigator.language || "en-US", { const hourFormatter = new Intl.DateTimeFormat(navigator.language || "en-US", {
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
timeZone: userTimeZone,
}); });
const timestampFormatter = new Intl.DateTimeFormat( const timestampFormatter = new Intl.DateTimeFormat(
navigator.language || "en-US", navigator.language || "en-US",
{ {
month: "long", month: "long",
day: "numeric", day: "numeric",
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
second: "numeric", second: "numeric",
} timeZone: userTimeZone,
},
); );
export function EventsBarChart({ export function EventsBarChart({
data, data,
chartConfig, chartConfig,
}: { }: {
data: { data: {
meta: any[]; meta: any[];
rows: number; rows: number;
data: Row[]; data: Row[];
}; };
chartConfig: any; chartConfig: any;
}) { }) {
const { selectedInterval } = useAnalyticsContext(); const { selectedInterval } = useAnalyticsContext();
const [options, setOptions] = useState<AgChartOptions>({ const [options, setOptions] = useState<AgChartOptions>({
data: data.data, data: data.data,
series: chartConfig, series: chartConfig,
theme: { theme: {
params: { params: {
fontFamily: { fontFamily: {
googleFont: "Inter", googleFont: "Inter",
}, },
}, },
palette: { palette: {
fills: [ fills: [
"#9c5aff", "#9c5aff",
"#a97eff", "#a97eff",
"#8268ff", "#8268ff",
"#7571ff", "#7571ff",
"#687aff", "#687aff",
"#5b83ff", "#5b83ff",
"#4e8cff", "#4e8cff",
"#4195ff", "#4195ff",
"#349eff", "#349eff",
"#27a7ff", "#27a7ff",
], ],
}, },
}, },
background: { background: {
fill: "#fafaf9", fill: "#fafaf9",
}, },
axes: [ axes: [
{ {
type: "category", type: "category",
position: "bottom", position: "bottom",
label: { label: {
color: "#52525b", color: "#52525b",
}, },
line: { line: {
enabled: false, enabled: false,
}, },
}, },
{ {
type: "number", type: "number",
position: "left", position: "left",
label: { label: {
color: "#52525b", color: "#52525b",
}, },
}, },
], ],
formatter: { formatter: {
x: (params: FormatterParams<any, unknown>) => { x: (params: FormatterParams<any, unknown>) => {
if (params.type !== "category") return; if (params.type !== "category") return;
return selectedInterval === "24h" return selectedInterval === "24h"
? hourFormatter.format(new Date(params.value as string)) ? hourFormatter.format(parseUTCTimestamp(params.value as string))
: dateFormatter.format(new Date(params.value as string)); : dateFormatter.format(parseUTCTimestamp(params.value as string));
}, },
}, },
legend: { legend: {
enabled: true, enabled: true,
}, },
}); });
const chartData = data.data; useEffect(() => {
setOptions((prevOptions) => ({
...prevOptions,
data: data.data,
series: chartConfig,
}));
}, [chartConfig, data]);
useEffect(() => { return <AgCharts options={options} className="h-full w-full" />;
setOptions({
...options,
data: data.data,
series: chartConfig,
});
}, [chartConfig, data]);
return <AgCharts options={options} className="h-full w-full" />;
} }
export function EventsAGGrid({ data }: { data: any }) { export function EventsAGGrid({ data }: { data: any }) {
const [rowData, setRowData] = useState<IRow[]>([]); const [rowData, setRowData] = useState<IRow[]>([]);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [event, setEvent] = useState<IRow | null>(null); const [event, setEvent] = useState<IRow | null>(null);
const [colDefs] = useState<ColDef<IRow>[]>([ const [colDefs] = useState<ColDef<IRow>[]>([
{ {
field: "timestamp", field: "timestamp",
flex: 1, flex: 1,
valueFormatter: (params: ValueFormatterParams<any, unknown>) => { valueFormatter: (params: ValueFormatterParams<any, unknown>) => {
return timestampFormatter.format(new Date(params.value as string)); return timestampFormatter.format(parseUTCTimestamp(params.value as string));
}, },
cellStyle: { cellStyle: {
paddingLeft: "2.5rem", paddingLeft: "2.5rem",
fontWeight: "normal", fontWeight: "normal",
}, },
headerStyle: { headerStyle: {
paddingLeft: "2.5rem", paddingLeft: "2.5rem",
}, },
}, },
{ field: "event_name", flex: 1, cellStyle: { fontWeight: "normal" } }, { field: "event_name", flex: 1, cellStyle: { fontWeight: "normal" } },
{ field: "value", flex: 0, cellStyle: { fontWeight: "normal" } }, { field: "value", flex: 0, cellStyle: { fontWeight: "normal" } },
{ field: "properties", flex: 1, cellStyle: { fontWeight: "normal" } }, { field: "properties", flex: 1, cellStyle: { fontWeight: "normal" } },
]); ]);
ModuleRegistry.registerModules([AllCommunityModule, ValidationModule]); ModuleRegistry.registerModules([AllCommunityModule, ValidationModule]);
const { gridRef, pageSize, setTotalRows, setTotalPages, setCurrentPage } = const { gridRef, pageSize, setTotalRows, setTotalPages, setCurrentPage } =
useAnalyticsContext(); useAnalyticsContext();
useEffect(() => { useEffect(() => {
setRowData(data.data); setRowData(data.data);
}, [data]); }, [data]);
return ( return (
<div className="w-full h-full overflow-hidden"> <div className="w-full h-full overflow-hidden">
<AgGridReact <AgGridReact
ref={gridRef} ref={gridRef}
rowData={rowData} rowData={rowData}
columnDefs={colDefs as any} columnDefs={colDefs as any}
domLayout="normal" domLayout="normal"
pagination={true} pagination={true}
paginationPageSize={pageSize} paginationPageSize={pageSize}
paginationPageSizeSelector={paginationOptions} paginationPageSizeSelector={paginationOptions}
suppressPaginationPanel={true} suppressPaginationPanel={true}
className="w-full h-full" className="w-full h-full"
theme={autumnTheme} theme={autumnTheme}
defaultColDef={{ defaultColDef={{
flex: 1, flex: 1,
resizable: true, resizable: true,
sortable: true, sortable: true,
filter: true, filter: true,
}} }}
onRowClicked={(event) => { onRowClicked={(event) => {
setEvent(event.data as IRow); setEvent(event.data as IRow);
setIsOpen(true); setIsOpen(true);
}} }}
onRowDataUpdated={(event: RowDataUpdatedEvent) => { onRowDataUpdated={(event: RowDataUpdatedEvent) => {
setTotalRows(event.api.paginationGetRowCount()); setTotalRows(event.api.paginationGetRowCount());
}} }}
onPaginationChanged={(event: PaginationChangedEvent) => { onPaginationChanged={(event: PaginationChangedEvent) => {
setTotalPages(event.api.paginationGetTotalPages()); setTotalPages(event.api.paginationGetTotalPages());
setCurrentPage(event.api.paginationGetCurrentPage() + 1); setCurrentPage(event.api.paginationGetCurrentPage() + 1);
}} }}
/> />
{event && ( {event && (
<RowClickDialog event={event} isOpen={isOpen} setIsOpen={setIsOpen} /> <RowClickDialog event={event} isOpen={isOpen} setIsOpen={setIsOpen} />
)} )}
</div> </div>
); );
} }

View File

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

View File

@@ -30,207 +30,133 @@ import { DiscountConfig } from "./DiscountConfig";
import { FreeDurationSelect } from "./FreeDurationSelect"; import { FreeDurationSelect } from "./FreeDurationSelect";
export const RewardConfig = ({ export const RewardConfig = ({
reward, reward,
setReward, setReward,
}: { }: {
reward: Reward; reward: Reward;
setReward: (reward: Reward) => void; setReward: (reward: Reward) => void;
}) => { }) => {
const [idChanged, setIdChanged] = useState(false); const [idChanged, setIdChanged] = useState(false);
const { products } = useProductsQuery(); const { products } = useProductsQuery();
const { org } = useOrg();
useEffect(() => { useEffect(() => {
if (!idChanged) { if (!idChanged) {
setReward({ setReward({
...reward, ...reward,
id: slugify(reward.name || ""), id: slugify(reward.name || ""),
}); });
} }
}, [reward, idChanged, setReward]); }, [idChanged, reward, setReward]);
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-6/12"> <div className="w-6/12">
<FieldLabel description="Will be shown on receipt">Name</FieldLabel> <FieldLabel description="Will be shown on receipt">Name</FieldLabel>
<Input <Input
value={reward.name || ""} value={reward.name || ""}
onChange={(e) => setReward({ ...reward, name: e.target.value })} onChange={(e) => setReward({ ...reward, name: e.target.value })}
/> />
</div> </div>
<div className="w-6/12"> <div className="w-6/12">
<FieldLabel description="Used to identify reward in API"> <FieldLabel description="Used to identify reward in API">
ID ID
</FieldLabel> </FieldLabel>
<Input <Input
value={reward.id || ""} value={reward.id || ""}
onChange={(e) => { onChange={(e) => {
setReward({ ...reward, id: e.target.value }); setReward({ ...reward, id: e.target.value });
setIdChanged(true); setIdChanged(true);
}} }}
/> />
</div> </div>
</div> </div>
<div className="flex items-center w-full gap-2"> <div className="flex items-center w-full gap-2">
<div className="w-full"> <div className="w-full">
<FieldLabel>Promotional Code</FieldLabel> <FieldLabel>Promotional Code</FieldLabel>
<Input <Input
value={ value={
reward.promo_codes.length > 0 ? reward.promo_codes[0].code : "" reward.promo_codes.length > 0 ? reward.promo_codes[0].code : ""
} }
onChange={(e) => onChange={(e) =>
setReward({ setReward({
...reward, ...reward,
promo_codes: [{ code: e.target.value }], promo_codes: [{ code: e.target.value }],
}) })
} }
/> />
</div> </div>
<div className="w-full"> <div className="w-full">
<FieldLabel>Type</FieldLabel> <FieldLabel>Type</FieldLabel>
<Select <Select
value={reward.type} value={reward.type}
onValueChange={(value) => { onValueChange={(value) => {
setReward({ setReward({
...reward, ...reward,
type: value as RewardType, type: value as RewardType,
discount_config: discount_config:
value === RewardType.FreeProduct value === RewardType.FreeProduct
? null ? null
: defaultDiscountConfig, : defaultDiscountConfig,
}); });
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a discount type" /> <SelectValue placeholder="Select a discount type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.values(RewardType).map((type) => ( {Object.values(RewardType).map((type) => (
<SelectItem key={type} value={type}> <SelectItem key={type} value={type}>
{keyToTitle(type)} {keyToTitle(type)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
{reward.type === RewardType.FreeProduct ? ( {reward.type === RewardType.FreeProduct ? (
<div> <div>
<div> <FieldLabel description="Select a free add-on product to give away">
<FieldLabel Product
description="Select a product to give away" </FieldLabel>
tooltip="If the referrer/redeemer already has the product, it will not be added to them." <Select
> value={reward.free_product_id || undefined}
Product onValueChange={(value) =>
</FieldLabel> setReward({ ...reward, free_product_id: value })
</div> }
<Select >
value={reward.free_product_id || undefined} {(() => {
onValueChange={(value) => const freeAddOns = products
setReward({ ...reward, free_product_id: value }) .filter((product: ProductV2) => product.is_add_on)
} .filter((product: ProductV2) => isFreeProduct(product.items));
>
{(() => {
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 const empty = freeAddOns.length === 0;
...products return (
.filter((product: ProductV2) => product.is_add_on) <>
.filter((product: ProductV2) => isFreeProduct(product.items)), <SelectTrigger disabled={empty}>
]; <SelectValue
placeholder={
const empty = filteredProducts.length === 0; empty
return ( ? "Create a free add-on product first"
<> : "Select a product"
<SelectTrigger disabled={empty}> }
<SelectValue />
placeholder={ </SelectTrigger>
empty <SelectContent>
? "Create a free add-on or paid product first" {freeAddOns.map((product: ProductV2) => (
: "Select a product" <SelectItem key={product.id} value={product.id}>
} {product.name}
/> </SelectItem>
</SelectTrigger> ))}
<SelectContent> </SelectContent>
{filteredProducts.map((product: ProductV2) => ( </>
<SelectItem key={product.id} value={product.id}> );
{product.name} })()}
</SelectItem> </Select>
))} </div>
</SelectContent> ) : notNullish(reward.type) ? (
</> <DiscountConfig reward={reward} setReward={setReward} />
); ) : null}
})()} </div>
</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,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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
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 { useRewardsQuery } from "@/hooks/queries/useRewardsQuery"; 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"; 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({ function UpdateReward({
open, open,
setOpen, setOpen,
selectedReward, selectedReward,
setSelectedReward, setSelectedReward,
}: { }: {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
selectedReward: Reward | null; selectedReward: Reward | null;
setSelectedReward: (reward: Reward) => void; setSelectedReward: (reward: Reward) => void;
}) { }) {
const [updateLoading, setUpdateLoading] = useState(false); const [updateLoading, setUpdateLoading] = useState(false);
const { refetch } = useRewardsQuery(); const { refetch } = useRewardsQuery();
const { products } = useProductsQuery();
const env = useEnv(); const env = useEnv();
const axiosInstance = useAxiosInstance({ env }); const axiosInstance = useAxiosInstance({ env });
const handleUpdate = async () => { if (!selectedReward) {
setUpdateLoading(true); setOpen(false);
try { return;
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);
};
return ( const handleUpdate = async () => {
<Dialog open={open} onOpenChange={setOpen}> setUpdateLoading(true);
<DialogContent className="w-[500px]"> try {
<DialogTitle>Update Reward</DialogTitle> // Check migration status and show warning if needed
<WarningBox> if (products) {
Existing customers with this coupon will not be affected const migrationResult = checkRewardMigration(selectedReward, products);
</WarningBox> 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 && ( await RewardService.updateReward({
<RewardConfig reward={selectedReward} setReward={setSelectedReward} /> 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> return (
<Button <Dialog open={open} onOpenChange={setOpen}>
isLoading={updateLoading} <DialogContent className="w-[500px]">
onClick={() => handleUpdate()} <DialogTitle>Update Reward</DialogTitle>
variant="gradientPrimary" <WarningBox>
> Existing customers with this coupon will not be affected
Update </WarningBox>
</Button>
</DialogFooter> {selectedReward && (
</DialogContent> <RewardConfig reward={selectedReward} setReward={setSelectedReward} />
</Dialog> )}
);
<DialogFooter>
<Button
isLoading={updateLoading}
onClick={() => handleUpdate()}
variant="gradientPrimary"
>
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
} }
export default UpdateReward; export default UpdateReward;

View File

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