diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index ea6dd3300..1af382567 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: AutumnInt is used for internal testing & scripts */ import dotenv from "dotenv"; dotenv.config(); @@ -50,12 +51,14 @@ export class AutumnInt { baseUrl, version, orgConfig, + liveUrl = false, }: { apiKey?: string; secretKey?: string; baseUrl?: string; version?: string | APIVersion; orgConfig?: Partial; + liveUrl?: boolean; } = {}) { // this.apiKey = apiKey || process.env.AUTUMN_API_KEY || ""; this.apiKey = @@ -74,7 +77,9 @@ export class AutumnInt { this.headers["org-config"] = JSON.stringify(orgConfig); } - this.baseUrl = baseUrl || "http://localhost:8080/v1"; + this.baseUrl = + baseUrl || + (liveUrl ? "https://api.useautumn.com/v1" : "http://localhost:8080/v1"); } async get(path: string) { @@ -91,13 +96,13 @@ export class AutumnInt { body: JSON.stringify(body), }); - if (response.status != 200) { + if (response.status !== 200) { let error: any; try { error = await response.json(); } catch (error) { throw new AutumnError({ - message: "Failed to parse Autumn API error response", + message: `AutumnInt post request failed, error: ${error}`, code: ErrCode.InternalError, }); } @@ -127,13 +132,13 @@ export class AutumnInt { }, ); - if (response.status != 200) { + if (response.status !== 200) { let error: any; try { error = await response.json(); } catch (error) { throw new AutumnError({ - message: "Failed to parse Autumn API error response", + message: `AutumnInt delete request failed, error: ${error}`, code: ErrCode.InternalError, }); } @@ -181,11 +186,6 @@ export class AutumnInt { async checkout( params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean }, ) { - // const data = await this.post(`/attach`, { - // customer_id: customerId, - // product_id: productId, - // options: toSnakeCase(options), - // }); const data = await this.post(`/checkout`, params); return data as CheckoutResult; @@ -249,6 +249,13 @@ export class AutumnInt { } customers = { + list: async (params?: { limit?: number; offset?: number }) => { + const data = await this.get( + `/customers?${new URLSearchParams(params as Record).toString()}`, + ); + return data; + }, + get: async ( customerId: string, params?: { diff --git a/server/src/external/stripe/stripeSubUtils.ts b/server/src/external/stripe/stripeSubUtils.ts index 81010402f..2fdba7ae0 100644 --- a/server/src/external/stripe/stripeSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils.ts @@ -1,19 +1,21 @@ -import Stripe from "stripe"; import { - BillingInterval, + type BillingInterval, CusProductStatus, - Feature, - FullCusProduct, - Organization, + type Feature, + type FullCusProduct, + type Organization, ProrationBehavior, - UsagePriceConfig, } from "@autumn/shared"; import { differenceInSeconds } from "date-fns"; +import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; // import { ProrationBehavior } from "@/internal/customers/change-product/handleUpgrade.js"; import { SubService } from "@/internal/subscriptions/SubService.js"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { getEarliestPeriodEnd } from "./stripeSubUtils/convertSubUtils.js"; import { notNullish } from "@/utils/genUtils.js"; +import { + getEarliestPeriodEnd, + getLatestPeriodEnd, +} from "./stripeSubUtils/convertSubUtils.js"; export const getFullStripeSub = async ({ stripeCli, @@ -52,15 +54,23 @@ export const getStripeSubs = async ({ } }; - for (const subId of subIds) { + const uniqueSubIds = Array.from(new Set(subIds)); + for (const subId of uniqueSubIds) { batchGet.push(getStripeSub(subId)); } + let subs = await Promise.all(batchGet); subs = subs.filter((sub) => sub !== null); // Sort by current_period_end (latest first) - subs.sort((a: any, b: any) => { - return b.current_period_end - a.current_period_end; + subs.sort((a, b) => { + if (!a || !b) { + return 0; + } + + const aLatestPeriodEnd = getLatestPeriodEnd({ sub: a }); + const bLatestPeriodEnd = getLatestPeriodEnd({ sub: b }); + return bLatestPeriodEnd - aLatestPeriodEnd; }); return subs as Stripe.Subscription[]; @@ -120,9 +130,9 @@ export const getUsageBasedSub = async ({ }); } - let finalSubIds = subs.map((sub) => sub.id); + const finalSubIds = subs.map((sub) => sub.id); - let autumnSubs = await SubService.getInStripeIds({ + const autumnSubs = await SubService.getInStripeIds({ db, ids: finalSubIds, }); @@ -131,9 +141,9 @@ export const getUsageBasedSub = async ({ let usageFeatures: string[] | null = null; // 1. Check if there's autumn sub - let autumnSub = autumnSubs?.find((sub) => sub.stripe_id == stripeSub.id); + const autumnSub = autumnSubs?.find((sub) => sub.stripe_id == stripeSub.id); if (autumnSub) { - let containsFeature = autumnSub.usage_features.includes( + const containsFeature = autumnSub.usage_features.includes( feature.internal_id!, ); if (containsFeature) { @@ -169,26 +179,25 @@ export const getSubItemsForCusProduct = async ({ stripeSub: Stripe.Subscription; cusProduct: FullCusProduct; }) => { - let prices = cusProduct.customer_prices.map((cp) => cp.price); - let product = cusProduct.product; + const prices = cusProduct.customer_prices.map((cp) => cp.price); + const product = cusProduct.product; - let subItems = []; + const subItems: Stripe.SubscriptionItem[] = []; for (const item of stripeSub.items.data) { - if (item.price.product == product.processor?.id) { + if (item.price.product === product.processor?.id) { subItems.push(item); } else if ( prices.some( (p) => - p.config?.stripe_price_id == item.price.id || - (p.config as UsagePriceConfig).stripe_product_id == - item.price.product, + p.config.stripe_price_id === item.price.id || + p.config.stripe_product_id === item.price.product, ) ) { subItems.push(item); } } - let otherSubItems = stripeSub.items.data.filter( - (item) => !subItems.some((i) => i.id == item.id), + const otherSubItems = stripeSub.items.data.filter( + (item) => !subItems.some((i) => i.id === item.id), ); return { subItems, otherSubItems }; @@ -244,7 +253,7 @@ export const getStripeSchedules = async ({ batchGet.push(getStripeSchedule(scheduleId)); } - let schedulesAndSubs = await Promise.all(batchGet); + const schedulesAndSubs = await Promise.all(batchGet); return schedulesAndSubs.filter((schedule) => schedule !== null) as { schedule: Stripe.SubscriptionSchedule; @@ -287,7 +296,7 @@ export const getStripeProrationBehavior = ({ org: Organization; prorationBehavior?: ProrationBehavior; }) => { - let behaviourMap = { + const behaviourMap = { [ProrationBehavior.Immediately]: "always_invoice", [ProrationBehavior.NextBilling]: "create_prorations", [ProrationBehavior.None]: "none", diff --git a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts index 3fc5b7c8c..253e5b8a8 100644 --- a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts +++ b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts @@ -1,42 +1,41 @@ -import { getExistingUsageFromCusProducts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { - getPriceEntitlement, - getEntOptions, - getProductForPrice, -} from "@/internal/products/prices/priceUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; -import { - FullProduct, - Price, - EntitlementWithFeature, - FeatureOptions, - Organization, - FullCusProduct, + type APIVersion, + type AttachConfig, + type AttachReplaceable, BillingInterval, - Entity, - APIVersion, - InsertReplaceable, - AttachReplaceable, + type EntitlementWithFeature, + type Entity, ErrCode, - AttachConfig, - ProductOptions, + type FeatureOptions, + type FullCusProduct, + type FullProduct, + isUsagePrice, + type Organization, + type Price, + type ProductOptions, } from "@autumn/shared"; -import { priceToStripeItem } from "../priceToStripeItem/priceToStripeItem.js"; -import { getArrearItems } from "./getStripeSubItems/getArrearItems.js"; -import { - compareBillingIntervals, - sortPricesByInterval, -} from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; -import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; -import RecaseError from "@/utils/errorUtils.js"; import { logger } from "@/external/logtail/logtailUtils.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { getExistingUsageFromCusProducts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { intervalKeyToPrice, priceToIntervalKey, priceToProductOptions, } from "@/internal/products/prices/priceUtils/convertPrice.js"; -import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { ItemSet } from "@/utils/models/ItemSet.js"; +import { + compareBillingIntervals, + sortPricesByInterval, +} from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; +import { + getEntOptions, + getPriceEntitlement, + getProductForPrice, +} from "@/internal/products/prices/priceUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; +import type { ItemSet } from "@/utils/models/ItemSet.js"; +import { priceToStripeItem } from "../priceToStripeItem/priceToStripeItem.js"; +import { getArrearItems } from "./getStripeSubItems/getArrearItems.js"; const getIntervalToPrices = (prices: Price[]) => { const intervalToPrices: Record = {}; @@ -50,14 +49,14 @@ const getIntervalToPrices = (prices: Price[]) => { intervalToPrices[key].push(price); } - let oneOffPrices = + const oneOffPrices = intervalToPrices[BillingInterval.OneOff] && intervalToPrices[BillingInterval.OneOff].length > 0; // If there are multiple intervals, add one off prices to first interval if (oneOffPrices && Object.keys(intervalToPrices).length > 1) { const nextIntervalKey = Object.keys(intervalToPrices)[0]; - intervalToPrices[nextIntervalKey!].push( + intervalToPrices[nextIntervalKey].push( ...structuredClone(intervalToPrices[BillingInterval.OneOff]), ); delete intervalToPrices[BillingInterval.OneOff]; @@ -107,8 +106,8 @@ export const getStripeSubItems = async ({ for (const intervalKey in intervalToPrices) { const prices = intervalToPrices[intervalKey]; - let subItems: any[] = []; - let usage_features: any[] = []; + const subItems: any[] = []; + const usage_features: any[] = []; for (const price of prices) { const prodOptions = priceToProductOptions({ @@ -128,7 +127,7 @@ export const getStripeSubItems = async ({ internalEntityId, }); - let replaceables = priceEnt + const replaceables = priceEnt ? attachParams.replaceables.filter((r) => r.ent.id === priceEnt.id) : []; @@ -141,7 +140,7 @@ export const getStripeSubItems = async ({ }); } - let product = getProductForPrice(price, products)!; + const product = getProductForPrice(price, products); if (!product) { logger.error( @@ -183,7 +182,7 @@ export const getStripeSubItems = async ({ } const { interval, intervalCount } = intervalKeyToPrice(intervalKey); - if (subItems.length == 0) { + if (subItems.length === 0) { subItems.push( ...getArrearItems({ prices, @@ -260,13 +259,13 @@ export const getStripeSubItems2 = async ({ internalEntityId, }); - let replaceables = priceEnt + const replaceables = priceEnt ? attachParams.replaceables.filter((r) => r.ent.id === priceEnt.id) : []; existingUsage += replaceables.length; - let product = getProductForPrice(price, attachParams.products)!; + const product = getProductForPrice(price, attachParams.products)!; if (!product) { logger.error( diff --git a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts index 0649eb84e..fb7fbe96d 100644 --- a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts +++ b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts @@ -1,17 +1,15 @@ -import { cusProductToPrices } from "@autumn/shared"; -import { getBillingType } from "@/internal/products/prices/priceUtils.js"; -import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; -import { notNullish } from "@/utils/genUtils.js"; import { - BillingType, - FullCusProduct, - Organization, - Price, - prices, + type BillingType, + cusProductToPrices, + type FullCusProduct, + type Price, PriceType, - UsagePriceConfig, + type UsagePriceConfig, } from "@autumn/shared"; -import Stripe from "stripe"; +import type Stripe from "stripe"; +import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { getBillingType } from "@/internal/products/prices/priceUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; const autumnStripePricesMatch = ({ stripePrice, @@ -24,16 +22,16 @@ const autumnStripePricesMatch = ({ }) => { const config = autumnPrice.config as UsagePriceConfig; - if (config.type == PriceType.Fixed) { + if (config.type === PriceType.Fixed) { return ( - config.stripe_price_id == stripePrice.id || - (stripeProdId && stripePrice.product == stripeProdId) + config.stripe_price_id === stripePrice.id || + (stripeProdId && stripePrice.product === stripeProdId) ); } else { return ( - config.stripe_price_id == stripePrice.id || - config.stripe_product_id == stripePrice.product || - config.stripe_empty_price_id == stripePrice.id + config.stripe_price_id === stripePrice.id || + config.stripe_product_id === stripePrice.product || + config.stripe_empty_price_id === stripePrice.id ); } }; @@ -79,7 +77,7 @@ export const findStripeItemForPrice = ({ }) => { if (invoiceLineItems) { return invoiceLineItems.find((li) => { - return li.pricing?.price_details?.price == price.config.stripe_price_id; + return li.pricing?.price_details?.price === price.config.stripe_price_id; }); } @@ -89,9 +87,9 @@ export const findStripeItemForPrice = ({ const config = price.config as UsagePriceConfig; return ( - config.stripe_price_id == si.price?.id || - config.stripe_product_id == si.price?.product || - config.stripe_empty_price_id == si.price?.id + config.stripe_price_id === si.price?.id || + config.stripe_product_id === si.price?.product || + config.stripe_empty_price_id === si.price?.id ); }, ); @@ -105,31 +103,14 @@ export const findStripeItemForPrice = ({ const config = price.config as UsagePriceConfig; return ( - config.stripe_price_id == si.price?.id || - (stripeProdId && si.price?.product == stripeProdId) + config.stripe_price_id === si.price?.id || + (stripeProdId && si.price?.product === stripeProdId) ); }, ); } return undefined; - - // return stripeItems.find((si: Stripe.SubscriptionItem | Stripe.LineItem) => { - // const config = price.config as UsagePriceConfig; - - // if (config.type == PriceType.Fixed) { - // return ( - // config.stripe_price_id == si.price?.id || - // (stripeProdId && si.price?.product == stripeProdId) - // ); - // } else { - // return ( - // config.stripe_price_id == si.price?.id || - // config.stripe_product_id == si.price?.product || - // config.stripe_empty_price_id == si.price?.id - // ); - // } - // }); } }; @@ -145,24 +126,26 @@ export const findPriceInStripeItems = ({ billingType?: BillingType; }) => { return prices.find((p: Price) => { - let config = p.config; + const config = p.config; - let itemMatch; + let itemMatch: boolean = false; if (subItem) { itemMatch = - config.stripe_price_id == subItem.price?.id || - config.stripe_product_id == subItem.price?.product; + config.stripe_price_id === subItem.price?.id || + config.stripe_product_id === subItem.price?.product; } if (lineItem) { const priceDetails = lineItem.pricing?.price_details; itemMatch = - config.stripe_price_id == priceDetails?.price || - config.stripe_product_id == priceDetails?.product; + config.stripe_price_id === priceDetails?.price || + config.stripe_product_id === priceDetails?.product; } const priceBillingType = getBillingType(config); - let billingTypeMatch = billingType ? priceBillingType == billingType : true; + const billingTypeMatch = billingType + ? priceBillingType === billingType + : true; return itemMatch && billingTypeMatch; }); @@ -190,14 +173,14 @@ export const lineItemInCusProduct = ({ cusProduct: FullCusProduct; lineItem: Stripe.InvoiceLineItem; }) => { - let stripeProdId = cusProduct.product.processor?.id; + const stripeProdId = cusProduct.product.processor?.id; - let prices = cusProductToPrices({ cusProduct }); - let price = findPriceInStripeItems({ prices, lineItem }); + const prices = cusProductToPrices({ cusProduct }); + const price = findPriceInStripeItems({ prices, lineItem }); const priceDetails = lineItem.pricing?.price_details; - return stripeProdId == priceDetails?.product || notNullish(price); + return stripeProdId === priceDetails?.product || notNullish(price); }; export const subItemInCusProduct = ({ @@ -207,12 +190,12 @@ export const subItemInCusProduct = ({ cusProduct: FullCusProduct; subItem: Stripe.SubscriptionItem; }) => { - let stripeProdId = cusProduct.product.processor?.id; + const stripeProdId = cusProduct.product.processor?.id; - let prices = cusProductToPrices({ cusProduct }); - let price = findPriceInStripeItems({ prices, subItem }); + const prices = cusProductToPrices({ cusProduct }); + const price = findPriceInStripeItems({ prices, subItem }); - return stripeProdId == subItem.price.product || notNullish(price); + return stripeProdId === subItem.price.product || notNullish(price); }; export const scheduleItemToPrice = ({ @@ -247,10 +230,10 @@ export const scheduleItemInCusProduct = ({ cusProduct: FullCusProduct; scheduleItem: Stripe.SubscriptionSchedule.Phase.Item; }) => { - let stripeProdId = cusProduct.product.processor?.id; + const stripeProdId = cusProduct.product.processor?.id; - let autumnPrices = cusProductToPrices({ cusProduct }); - let price = autumnPrices.find((p) => { + const autumnPrices = cusProductToPrices({ cusProduct }); + const price = autumnPrices.find((p) => { const stripePrice = scheduleItem.price as Stripe.Price; return autumnStripePricesMatch({ @@ -268,7 +251,7 @@ export const isLicenseItem = ({ }: { stripeItem: Stripe.SubscriptionItem | Stripe.LineItem; }) => { - return stripeItem.price?.recurring?.usage_type == "licensed"; + return stripeItem.price?.recurring?.usage_type === "licensed"; }; export const isMeteredItem = ({ @@ -276,7 +259,7 @@ export const isMeteredItem = ({ }: { stripeItem: Stripe.SubscriptionItem | Stripe.LineItem; }) => { - return stripeItem.price?.recurring?.usage_type == "metered"; + return stripeItem.price?.recurring?.usage_type === "metered"; }; // Get sub item from product diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts index ec6bb67f4..0007604e5 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts @@ -1,8 +1,7 @@ -import Stripe from "stripe"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { APIVersion, Organization, UsagePriceConfig } from "@autumn/shared"; -import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { APIVersion, isUsagePrice, type Organization } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { getEmptyPriceItem } from "../../priceToStripeItem/priceToStripeItem.js"; export const handleRemainingSets = async ({ @@ -23,10 +22,10 @@ export const handleRemainingSets = async ({ logger: any; }) => { const itemSets = attachParams.itemSets; - let remainingSets = itemSets ? itemSets.slice(1) : []; + const remainingSets = itemSets ? itemSets.slice(1) : []; const remainingItems = remainingSets.flatMap((set) => set.items); - let invoiceIds: string[] = checkoutSession.invoice + const invoiceIds: string[] = checkoutSession.invoice ? [checkoutSession.invoice as string] : []; @@ -34,18 +33,18 @@ export const handleRemainingSets = async ({ for (const price of attachParams.prices) { if (!isUsagePrice({ price })) continue; - const config = price.config as UsagePriceConfig; + const config = price.config; const emptyPrice = config.stripe_empty_price_id; if ( attachParams.internalEntityId || - attachParams.apiVersion == APIVersion.v1_4 + attachParams.apiVersion === APIVersion.v1_4 ) { const replaceIndex = remainingItems.findIndex( - (item) => item.price == config.stripe_price_id, + (item) => item.price === config.stripe_price_id, ); - if (replaceIndex != -1) { + if (replaceIndex !== -1) { remainingItems[replaceIndex] = emptyPrice ? { price: config.stripe_empty_price_id, diff --git a/server/src/internal/analytics/AnalyticsService.ts b/server/src/internal/analytics/AnalyticsService.ts index fa5afd96b..b3ac8f106 100644 --- a/server/src/internal/analytics/AnalyticsService.ts +++ b/server/src/internal/analytics/AnalyticsService.ts @@ -2,6 +2,7 @@ import { ErrCode, type FullCustomer } from "@autumn/shared"; import type { ClickHouseClient } from "@clickhouse/client"; +import { Decimal } from "decimal.js"; import { StatusCodes } from "http-status-codes"; import RecaseError from "@/utils/errorUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; @@ -302,7 +303,7 @@ order by dr.period; resultJson.data.forEach((row: any) => { Object.keys(row).forEach((key: string) => { if (key !== "period") { - row[key] = parseInt(row[key]); + row[key] = new Decimal(row[key]).toDecimalPlaces(10).toNumber(); } }); }); diff --git a/server/src/internal/analytics/internalAnalyticsRouter.ts b/server/src/internal/analytics/internalAnalyticsRouter.ts index 44ed8a3be..63f1091dd 100644 --- a/server/src/internal/analytics/internalAnalyticsRouter.ts +++ b/server/src/internal/analytics/internalAnalyticsRouter.ts @@ -1,21 +1,19 @@ +import { + ErrCode, + type Feature, + FeatureType, + type FullCusProduct, + type FullCustomer, +} from "@autumn/shared"; import { Router } from "express"; +import { StatusCodes } from "http-status-codes"; +import { CacheType } from "@/external/caching/cacheActions.js"; +import { queryWithCache } from "@/external/caching/cacheUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import type { ExtendedRequest } from "@/utils/models/Request.js"; +import { routeHandler } from "@/utils/routerUtils.js"; import { CusService } from "../customers/CusService.js"; import { AnalyticsService } from "./AnalyticsService.js"; -import { StatusCodes } from "http-status-codes"; -import { - AppEnv, - ErrCode, - Feature, - FeatureType, - FullCusProduct, - FullCustomer, - Organization, -} from "@autumn/shared"; -import RecaseError from "@/utils/errorUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { queryWithCache } from "@/external/caching/cacheUtils.js"; -import { CacheType } from "@/external/caching/cacheActions.js"; -import { ExtendedRequest } from "@/utils/models/Request.js"; export const analyticsRouter = Router(); @@ -28,7 +26,7 @@ analyticsRouter.get("/event_names", async (req: any, res: any) => const { db, org, env, features } = req; const { interval, event_names, customer_id } = req.body; - let result = await queryWithCache({ + const result = await queryWithCache({ action: CacheType.TopEvents, key: `${org.id}_${env}`, fn: async () => { @@ -46,15 +44,15 @@ analyticsRouter.get("/event_names", async (req: any, res: any) => // let result = topEventNamesRes?.eventNames; - let featureIds: string[] = []; - let eventNames: string[] = []; + const featureIds: string[] = []; + const eventNames: string[] = []; for (let i = 0; i < result.length; i++) { // Is an event name if ( features.some( (feature: Feature) => - feature.type == FeatureType.Metered && + feature.type === FeatureType.Metered && feature.config.filters?.[0]?.value.includes(result[i]), ) ) { @@ -95,17 +93,17 @@ const getTopEvents = async ({ req }: { req: ExtendedRequest }) => { req, }); - let result = topEventNamesRes?.eventNames; + const result = topEventNamesRes?.eventNames; - let featureIds: string[] = []; - let eventNames: string[] = []; + const featureIds: string[] = []; + const eventNames: string[] = []; for (let i = 0; i < result.length; i++) { // Is an event name if ( features.some( (feature: Feature) => - feature.type == FeatureType.Metered && + feature.type === FeatureType.Metered && feature.config.filters?.[0]?.value.includes(result[i]), ) ) { @@ -131,9 +129,7 @@ analyticsRouter.post("/events", async (req: any, res: any) => const { db, org, env, features } = req; let { interval, event_names, customer_id } = req.body; - let topEvents: - | { featureIds: string[]; eventNames: string[] } - | undefined = undefined; + let topEvents: { featureIds: string[]; eventNames: string[] } | undefined; if (!event_names || event_names.length === 0) { topEvents = await getTopEvents({ req }); @@ -141,7 +137,7 @@ analyticsRouter.post("/events", async (req: any, res: any) => } let aggregateAll = false; - let customer: FullCustomer | undefined = undefined; + let customer: FullCustomer | undefined; let bcExclusionFlag = false; if (!customer_id) { @@ -190,8 +186,6 @@ analyticsRouter.post("/events", async (req: any, res: any) => aggregateAll, }); - // console.log("events", events); - res.status(200).json({ customer, events, @@ -214,7 +208,7 @@ analyticsRouter.post("/raw", async (req: any, res: any) => const { interval, customer_id } = req.body; let aggregateAll = false; - let customer: FullCustomer | undefined = undefined; + let customer: FullCustomer | undefined; if (!customer_id) { // No customer ID provided, set aggregateAll to true diff --git a/server/src/internal/api/entitled/getCheckPreview.ts b/server/src/internal/api/entitled/getCheckPreview.ts index d1a11bae2..1b863de12 100644 --- a/server/src/internal/api/entitled/getCheckPreview.ts +++ b/server/src/internal/api/entitled/getCheckPreview.ts @@ -1,19 +1,23 @@ -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { fullCusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; -import { ProductService } from "@/internal/products/ProductService.js"; -import { - isOneOff, - isProductUpgrade, -} from "@/internal/products/productUtils.js"; -import { sortProductsByPrice } from "@/internal/products/productUtils/sortProductUtils.js"; -import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; -import { notNullish } from "@/utils/genUtils.js"; import { type Feature, FeaturePreviewScenario, type FullCusProduct, + type FullEntitlement, type FullProduct, } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { fullCusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; +import { + sortFullProducts, + sortProductsByPrice, +} from "@/internal/products/productUtils/sortProductUtils.js"; +import { + isOneOff, + isProductUpgrade, +} from "@/internal/products/productUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; export const getCheckPreview = async ({ db, @@ -34,30 +38,34 @@ export const getCheckPreview = async ({ return null; } - let mainCusProds = cusProducts.filter( + const mainCusProds = cusProducts.filter( (cp: FullCusProduct) => !cp.product.is_add_on, ); - let cusOwnedProducts = mainCusProds.map((cp: FullCusProduct) => + const cusOwnedProducts = mainCusProds.map((cp: FullCusProduct) => fullCusProductToProduct(cp), ); + sortProductsByPrice({ products: cusOwnedProducts }); - let highestTierProd = + + const highestTierProd = cusOwnedProducts.length > 0 ? cusOwnedProducts[0] : null; - let products: FullProduct[] = await ProductService.getByFeature({ + const products: FullProduct[] = await ProductService.getByFeature({ db, - internalFeatureId: feature.internal_id!, + internalFeatureId: feature.internal_id, }); + sortFullProducts({ products }); + // 1. Get add ons - let addOns = []; - for (let addOn of products) { + const addOns = []; + for (const addOn of products) { if (addOn.is_add_on) { if (isOneOff(addOn.prices)) { addOns.push(addOn); } else if ( - !cusProducts.some((cp: FullCusProduct) => cp.product.id == addOn.id) + !cusProducts.some((cp: FullCusProduct) => cp.product.id === addOn.id) ) { addOns.push(addOn); } @@ -68,12 +76,13 @@ export const getCheckPreview = async ({ if (!highestTierProd) { mainProds = products.filter((product: FullProduct) => !product.is_add_on); } else { - for (let prod of products) { + for (const prod of products) { if (prod.is_add_on) { continue; } - if (mainCusProds.some((cp: FullCusProduct) => cp.product.id == prod.id)) { - continue; + if ( + mainCusProds.some((cp: FullCusProduct) => cp.product.id === prod.id) + ) { } else if ( isProductUpgrade({ prices1: highestTierProd.prices, @@ -86,21 +95,21 @@ export const getCheckPreview = async ({ } } - let rawProducts = [...mainProds, ...addOns]; - for (let p of rawProducts) { + const rawProducts = [...mainProds, ...addOns]; + for (const p of rawProducts) { p.entitlements = p.entitlements.map((e) => ({ ...e, - feature: allFeatures.find((f) => f.id == e.feature_id)!, - })); + feature: allFeatures.find((f) => f.id === e.feature_id), + })) as FullEntitlement[]; } - let v2Prods = await Promise.all( + const v2Prods = await Promise.all( rawProducts.map((p) => getProductResponse({ product: p, features: allFeatures }), ), ); - let scenario = notNullish(balance) + const scenario = notNullish(balance) ? FeaturePreviewScenario.UsageLimit : FeaturePreviewScenario.FeatureFlag; @@ -111,7 +120,7 @@ export const getCheckPreview = async ({ feature_id: feature.id, feature_name: feature.name, message: - scenario == FeaturePreviewScenario.UsageLimit + scenario === FeaturePreviewScenario.UsageLimit ? `You have reached the usage limit for ${feature.name}. Please contact us to increase your limit.` : `${feature.name} is not available for your account. Please contact us to enable it.`, @@ -120,9 +129,9 @@ export const getCheckPreview = async ({ }; } - let nextProd = mainProds.length > 0 ? mainProds[0] : addOns[0]; + const nextProd = mainProds.length > 0 ? mainProds[0] : addOns[0]; - let title = nextProd.free_trial + const title = nextProd.free_trial ? `Start trial for ${nextProd.name}` : !nextProd.is_add_on ? `Upgrade to ${nextProd.name}` @@ -140,7 +149,7 @@ export const getCheckPreview = async ({ } msg = `${msg} ${prodString}`; } else if (addOns.length > 0) { - let prodString = `Please purchase the ${addOns[0].name} add on to continue using this feature.`; + const prodString = `Please purchase the ${addOns[0].name} add on to continue using this feature.`; msg = `${msg} ${prodString}`; } } @@ -155,12 +164,12 @@ export const getCheckPreview = async ({ } msg = `${msg} ${prodString}`; } else if (addOns.length > 0) { - let prodString = `Please purchase the ${addOns[0].name} add on to use this feature.`; + const prodString = `Please purchase the ${addOns[0].name} add on to use this feature.`; msg = `${msg} ${prodString}`; } } - let nextTier = + const nextTier = mainProds.length > 0 ? mainProds[0] : addOns.length > 0 ? addOns[0] : null; return { diff --git a/server/src/internal/api/rewards/rewardProgramRouter.ts b/server/src/internal/api/rewards/rewardProgramRouter.ts index 9b80ae01a..67cf5e54f 100644 --- a/server/src/internal/api/rewards/rewardProgramRouter.ts +++ b/server/src/internal/api/rewards/rewardProgramRouter.ts @@ -1,18 +1,18 @@ +import { + ErrCode, + nullish, + type RewardProgram, + RewardTriggerEvent, + UpdateRewardProgram, +} from "@autumn/shared"; import express, { type Router } from "express"; +import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { routeHandler } from "@/utils/routerUtils.js"; import { handleCreateRewardProgram, handleDeleteRewardProgram, } from "./handlers/rewardPrograms/index.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { - CreateRewardProgram, - ErrCode, - nullish, - RewardTriggerEvent, -} from "@autumn/shared"; -import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; -import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js"; export const rewardProgramRouter: Router = express.Router(); @@ -39,7 +39,7 @@ rewardProgramRouter.put("/:id", (req, res) => } // Ensure program exists - let existingProgram = await RewardProgramService.get({ + const existingProgram = await RewardProgramService.get({ db, idOrInternalId: id, orgId, @@ -54,21 +54,22 @@ rewardProgramRouter.put("/:id", (req, res) => }); } - const rewardProgram = constructRewardProgram({ - rewardProgramData: CreateRewardProgram.parse({ - ...body, - id: existingProgram.id, // ID cannot be changed - }), - orgId, - env, - }); + // const rewardProgram = constructRewardProgram({ + // rewardProgramData: CreateRewardProgram.parse({ + // ...body, + // id: existingProgram.id, // ID cannot be changed + // }), + // orgId, + // env, + // }); // Update on existing redemptions? (should be none unless affecting stacked rewards...) + const data = UpdateRewardProgram.parse(body); + if ( - rewardProgram.when == RewardTriggerEvent.Checkout && - (nullish(rewardProgram.product_ids) || - rewardProgram.product_ids!.length == 0) + data.when === RewardTriggerEvent.Checkout && + (nullish(data.product_ids) || data.product_ids.length === 0) ) { throw new RecaseError({ message: @@ -78,12 +79,12 @@ rewardProgramRouter.put("/:id", (req, res) => }); } - let updatedRewardProgram = await RewardProgramService.update({ + const updatedRewardProgram = await RewardProgramService.update({ db, idOrInternalId: id, orgId, env, - data: rewardProgram, + data: data as RewardProgram, }); return res.status(200).json(updatedRewardProgram); diff --git a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts index 2ccdc1ed3..f04ec62c0 100644 --- a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts +++ b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts @@ -22,8 +22,8 @@ import { import RecaseError from "@/utils/errorUtils.js"; import { attachParamsToCurCusProduct, + getCustomerSchedule, getCustomerSub, - paramsToCurSubSchedule, } from "../../attachUtils/convertAttachParams.js"; import { paramsToScheduleItems } from "../../mergeUtils/paramsToScheduleItems.js"; import { getCurrentPhaseIndex } from "../../mergeUtils/phaseUtils/phaseUtils.js"; @@ -54,7 +54,12 @@ export const handleScheduleFunction2 = async ({ const { sub: curSub } = await getCustomerSub({ attachParams }); // 1. Cancel current subscription and fetch items from other cus products...? - let schedule = await paramsToCurSubSchedule({ attachParams }); + let { schedule } = await getCustomerSchedule({ + attachParams, + subId: curSub?.id, + logger, + }); + if (!curSub) { throw new RecaseError({ message: `SCHEDULE FLOW, curSub is undefined`, diff --git a/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts b/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts index ffe07c245..23a9a02a2 100644 --- a/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts @@ -226,8 +226,8 @@ export const paramsToCurSub = async ({ }) => { const { stripeCli } = attachParams; const curCusProduct = attachParamsToCurCusProduct({ attachParams }); - console.log("Cur cus product:", curCusProduct); - console.log("Sub IDs:", curCusProduct?.subscription_ids); + // console.log("Cur cus product:", curCusProduct); + // console.log("Sub IDs:", curCusProduct?.subscription_ids); const subIds = curCusProduct?.subscription_ids || []; diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts index 6c9883005..a728f661b 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts @@ -1,17 +1,13 @@ -import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { - isArrearPrice, - isContUsePrice, + type AttachBody, + type AttachBranch, isUsagePrice, -} from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { - AttachBody, - AttachBranch, notNullish, nullish, - Price, + type Price, } from "@autumn/shared"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import RecaseError from "@/utils/errorUtils.js"; export const handleMultiAttachErrors = async ({ attachParams, diff --git a/server/src/internal/customers/attach/checkout/handleCheckout.ts b/server/src/internal/customers/attach/checkout/handleCheckout.ts index 1080a6ceb..22a7fc493 100644 --- a/server/src/internal/customers/attach/checkout/handleCheckout.ts +++ b/server/src/internal/customers/attach/checkout/handleCheckout.ts @@ -1,29 +1,32 @@ -import { AttachFunction, FeatureOptions } from "@autumn/shared"; - +import { + type AttachBody, + AttachBodySchema, + AttachFunction, + type FeatureOptions, +} from "@autumn/shared"; +import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js"; +import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { getPriceOptions } from "@/internal/products/prices/priceUtils.js"; +import type { + ExtendedRequest, + ExtendedResponse, +} from "@/utils/models/Request.js"; import { routeHandler } from "@/utils/routerUtils.js"; -import { getAttachParams } from "../attachUtils/attachParams/getAttachParams.js"; -import { AttachBody, AttachBodySchema } from "@autumn/shared"; -import { ExtendedResponse } from "@/utils/models/Request.js"; -import { ExtendedRequest } from "@/utils/models/Request.js"; -import { getAttachBranch } from "../attachUtils/getAttachBranch.js"; -import { getAttachConfig } from "../attachUtils/getAttachConfig.js"; -import { getAttachFunction } from "../attachUtils/getAttachFunction.js"; import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js"; +import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js"; +import type { AttachParams } from "../../cusProducts/AttachParams.js"; import { checkStripeConnections, handlePrepaidErrors, } from "../attachRouter.js"; -import { attachParamsToPreview } from "../handleAttachPreview/attachParamsToPreview.js"; -import { previewToCheckoutRes } from "./previewToCheckoutRes.js"; -import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { getAttachParams } from "../attachUtils/attachParams/getAttachParams.js"; import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js"; -import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; -import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js"; -import { getPriceOptions } from "@/internal/products/prices/priceUtils.js"; +import { getAttachBranch } from "../attachUtils/getAttachBranch.js"; +import { getAttachConfig } from "../attachUtils/getAttachConfig.js"; +import { getAttachFunction } from "../attachUtils/getAttachFunction.js"; +import { attachParamsToPreview } from "../handleAttachPreview/attachParamsToPreview.js"; import { getHasProrations } from "./getHasProrations.js"; -import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js"; -import { z } from "zod"; -import { formatUnixToDate, notNullish } from "@/utils/genUtils.js"; +import { previewToCheckoutRes } from "./previewToCheckoutRes.js"; const getAttachVars = async ({ req, @@ -79,13 +82,15 @@ const getCheckoutOptions = async ({ isPrepaidPrice({ price: p }), ); - let newOptions: FeatureOptions[] = structuredClone(attachParams.optionsList); + const newOptions: FeatureOptions[] = structuredClone( + attachParams.optionsList, + ); for (const prepaidPrice of prepaidPrices) { const feature = priceToFeature({ price: prepaidPrice, features: req.features, }); - let option = getPriceOptions(prepaidPrice, attachParams.optionsList); + const option = getPriceOptions(prepaidPrice, attachParams.optionsList); if (!option) { newOptions.push({ feature_id: feature?.id!, @@ -116,7 +121,7 @@ export const handleCheckout = (req: any, res: any) => let checkoutUrl = null; - if (func == AttachFunction.CreateCheckout) { + if (func === AttachFunction.CreateCheckout) { await checkStripeConnections({ req, attachParams, @@ -182,17 +187,9 @@ export const handleCheckout = (req: any, res: any) => attachParams, }); - if (checkoutRes.next_cycle) { - const nextCycle = checkoutRes.next_cycle; - } - res.status(200).json({ ...checkoutRes, url: checkoutUrl, - options: attachParams.optionsList.map((o) => ({ - quantity: o.quantity, - feature_id: o.feature_id, - })), has_prorations: hasProrations, }); diff --git a/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts b/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts index 02a063db6..b6b11394e 100644 --- a/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts +++ b/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts @@ -1,31 +1,30 @@ import { - AttachBranch, - AttachPreview, - CheckoutLine, + type AttachBranch, + type AttachPreview, + type CheckoutLine, CheckoutResponseSchema, - UsageModel, -} from "@autumn/shared"; - -import { AttachParams } from "../../cusProducts/AttachParams.js"; -import { - attachParamsToProduct, - attachParamToCusProducts, -} from "../attachUtils/convertAttachParams.js"; -import { cusProductToEnts, cusProductToPrices, cusProductToProduct, + isUsagePrice, + type PreviewLineItem, + toProductItem, + UsageModel, } from "@autumn/shared"; -import { ExtendedRequest } from "@/utils/models/Request.js"; +import { Decimal } from "decimal.js"; +import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js"; +import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js"; import { getProductItemResponse, getProductResponse, } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; -import { toProductItem } from "@autumn/shared"; -import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js"; -import { formatUnixToDateTime, notNullish } from "@/utils/genUtils.js"; -import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js"; -import { Decimal } from "decimal.js"; +import { notNullish } from "@/utils/genUtils.js"; +import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AttachParams } from "../../cusProducts/AttachParams.js"; +import { + attachParamsToProduct, + attachParamToCusProducts, +} from "../attachUtils/convertAttachParams.js"; export const previewToCheckoutRes = async ({ req, @@ -42,29 +41,29 @@ export const previewToCheckoutRes = async ({ const product = attachParamsToProduct({ attachParams }); const { curCusProduct } = attachParamToCusProducts({ attachParams }); - let curPrices = curCusProduct + const curPrices = curCusProduct ? cusProductToPrices({ cusProduct: curCusProduct }) : []; - let curEnts = curCusProduct + const curEnts = curCusProduct ? cusProductToEnts({ cusProduct: curCusProduct }) : []; - let newPrices = attachParams.prices; - let newEnts = attachParams.entitlements; - let allPrices = [...curPrices, ...newPrices]; - let allEnts = [...curEnts, ...newEnts]; + const newPrices = attachParams.prices; + const newEnts = attachParams.entitlements; + const allPrices = [...curPrices, ...newPrices]; + const allEnts = [...curEnts, ...newEnts]; let lines: CheckoutLine[] = []; if (preview.due_today && preview.due_today.line_items.length > 0) { lines = preview.due_today.line_items - .map((li: any) => { - let price = allPrices.find((p) => p.id == li.price_id); + .map((li: PreviewLineItem) => { + const price = allPrices.find((p) => p.id === li.price_id); if (!price) { return null; } - let ent = getPriceEntitlement(price, allEnts); + const ent = getPriceEntitlement(price, allEnts); return { description: li.description || "", @@ -89,7 +88,7 @@ export const previewToCheckoutRes = async ({ fullCus: attachParams.customer, }); - let curProduct = curCusProduct + const curProduct = curCusProduct ? await getProductResponse({ product: cusProductToProduct({ cusProduct: curCusProduct }), features, @@ -100,7 +99,12 @@ export const previewToCheckoutRes = async ({ const total = lines.reduce((acc, line) => acc + line.amount, 0); - let nextCycle = undefined; + let nextCycle: + | { + starts_at: number; + total: number; + } + | undefined; if ( notNullish(preview.due_next_cycle) && @@ -108,7 +112,7 @@ export const previewToCheckoutRes = async ({ ) { let total = newProduct.items .reduce((acc, item) => { - if (item.usage_model == UsageModel.PayPerUse) { + if (item.usage_model === UsageModel.PayPerUse) { return acc; } @@ -116,8 +120,8 @@ export const previewToCheckoutRes = async ({ return acc.plus(item.price || 0); } - let prepaidQuantity = - attachParams.optionsList.find((o) => o.feature_id == item.feature_id) + const prepaidQuantity = + attachParams.optionsList.find((o) => o.feature_id === item.feature_id) ?.quantity || 0; return acc.plus(prepaidQuantity * (item.price || 0)); @@ -126,8 +130,7 @@ export const previewToCheckoutRes = async ({ try { if ( - preview.due_next_cycle && - preview.due_next_cycle.line_items && + preview.due_next_cycle?.line_items && preview.due_next_cycle.line_items.length > 0 ) { total = preview.due_next_cycle.line_items @@ -148,6 +151,28 @@ export const previewToCheckoutRes = async ({ }; } + // Options + const options = attachParams.optionsList + .map((o) => { + const price = allPrices.find((p) => { + if (isUsagePrice({ price: p })) { + return ( + p.config.internal_feature_id === o.internal_feature_id || + p.config.feature_id === o.feature_id + ); + } + return false; + }); + + if (!price) return undefined; + + return { + feature_id: o.feature_id, + quantity: o.quantity * (price.config.billing_units || 1), + }; + }) + .filter(notNullish); + return CheckoutResponseSchema.parse({ customer_id: attachParams.customer.id, lines, @@ -159,5 +184,6 @@ export const previewToCheckoutRes = async ({ ? preview.due_next_cycle.due_at : null, next_cycle: nextCycle, + options, }); }; diff --git a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts index 9a3039951..599d2e8b7 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts @@ -1,25 +1,24 @@ import { AttachBranch, - AttachConfig, + type AttachConfig, BillingInterval, - FullProduct, - FreeTrial, + type FullProduct, + isTrialing, } from "@autumn/shared"; import { getOptions } from "@/internal/api/entitled/checkUtils.js"; import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; -import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; +import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; +import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js"; +import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; +import { mapToProductItems } from "@/internal/products/productV2Utils.js"; +import type { AttachParams } from "../../cusProducts/AttachParams.js"; import { attachParamsToProduct, getCustomerSub, } from "../attachUtils/convertAttachParams.js"; -import { mapToProductItems } from "@/internal/products/productV2Utils.js"; -import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; -import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; -import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; -import { isTrialing } from "@autumn/shared"; -import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js"; -import { formatUnixToDate } from "@/utils/genUtils.js"; +// Just for new product...? const getNextCycleItems = async ({ newProduct, attachParams, @@ -40,7 +39,8 @@ const getNextCycleItems = async ({ trialEnds?: number | null; }) => { // 2. If free trial - let nextCycleAt = undefined; + let nextCycleAt: number | undefined; + if (attachParams.freeTrial) { if (trialEnds) { nextCycleAt = trialEnds; @@ -51,7 +51,7 @@ const getNextCycleItems = async ({ now: attachParams.now, })! * 1000; } - } else if (branch != AttachBranch.OneOff && anchor) { + } else if (branch !== AttachBranch.OneOff && anchor) { // Yearly one const largestInterval = getLargestInterval({ prices: newProduct.prices }); if (largestInterval) { @@ -69,7 +69,7 @@ const getNextCycleItems = async ({ now: attachParams.now, logger, withPrepaid, - anchor, + // anchor, }); return { @@ -98,14 +98,14 @@ export const getNewProductPreview = async ({ attachParams, }); - let trialEnds = undefined; + let trialEnds: number | undefined; // Scenario where we update a current sub with new product (so no create sub) - let anchor = undefined; + let anchor: number | undefined; if (mergeSub && !config.disableMerge) { if (mergeCusProduct?.free_trial) { if (isTrialing({ cusProduct: mergeCusProduct, now: attachParams.now })) { - trialEnds = mergeCusProduct.trial_ends_at; + trialEnds = mergeCusProduct.trial_ends_at || undefined; attachParams.freeTrial = mergeCusProduct.free_trial; } else { attachParams.freeTrial = null; @@ -141,7 +141,7 @@ export const getNewProductPreview = async ({ trialEnds, }); - let options = getOptions({ + const options = getOptions({ prodItems: mapToProductItems({ prices: newProduct.prices, entitlements: newProduct.entitlements, diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts index 9e1f2fb7d..23c359817 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts @@ -1,42 +1,38 @@ -import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { + AttachBranch, + type AttachConfig, + cusProductToPrices, + cusProductToProduct, + type FreeTrial, + type FullCusProduct, + isTrialing, + OnDecrease, + OnIncrease, + type PreviewLineItem, + type Price, + UsageModel, + type UsagePriceConfig, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import type Stripe from "stripe"; +import { getOptions } from "@/internal/api/entitled/checkUtils.js"; +import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js"; +import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; +import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; +import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js"; +import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; +import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { isFreeProduct } from "@/internal/products/productUtils.js"; +import { mapToProductItems } from "@/internal/products/productV2Utils.js"; +import { nullish } from "@/utils/genUtils.js"; +import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AttachParams } from "../../cusProducts/AttachParams.js"; import { attachParamsToProduct, attachParamToCusProducts, paramsToCurSub, } from "../attachUtils/convertAttachParams.js"; -import { ExtendedRequest } from "@/utils/models/Request.js"; -import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; -import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; -import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js"; -import { getOptions } from "@/internal/api/entitled/checkUtils.js"; -import { mapToProductItems } from "@/internal/products/productV2Utils.js"; -import Stripe from "stripe"; -import { - AttachBranch, - FreeTrial, - FullCusProduct, - PreviewLineItem, - Price, - UsageModel, - AttachConfig, - UsagePriceConfig, - OnDecrease, - OnIncrease, -} from "@autumn/shared"; - -import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; -import { Decimal } from "decimal.js"; -import { isFreeProduct } from "@/internal/products/productUtils.js"; -import { formatUnixToDate, nullish } from "@/utils/genUtils.js"; -import { isTrialing } from "@autumn/shared"; -import { cusProductToPrices } from "@autumn/shared"; -import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; -import { - addIntervalToAnchor, - getAlignedUnix, -} from "@/internal/products/prices/billingIntervalUtils2.js"; - const getNextCycleAt = ({ prices, sub, @@ -46,7 +42,7 @@ const getNextCycleAt = ({ curCusProduct, }: { prices: Price[]; - sub: Stripe.Subscription; + sub?: Stripe.Subscription; now?: number; freeTrial?: FreeTrial | null; branch: AttachBranch; @@ -55,7 +51,7 @@ const getNextCycleAt = ({ now = now || Date.now(); if ( - branch == AttachBranch.NewVersion && + branch === AttachBranch.NewVersion && curCusProduct && isTrialing({ cusProduct: curCusProduct, now }) ) { @@ -72,7 +68,7 @@ const getNextCycleAt = ({ } const largestInterval = getLargestInterval({ prices }); - if (nullish(largestInterval) || !sub.billing_cycle_anchor) return now; + if (nullish(largestInterval) || !sub?.billing_cycle_anchor) return now; const nextCycleAt = getAlignedUnix({ anchor: sub.billing_cycle_anchor * 1000, @@ -101,17 +97,17 @@ const filterNoProratePrepaidItems = ({ for (const option of attachParams.optionsList) { const { feature_id, internal_feature_id, quantity } = option; const prevQuantity = curSameProduct?.options.find( - (o) => o.feature_id == feature_id, + (o) => o.feature_id === feature_id, )?.quantity; const curPrice = curPrices.find( (p) => - (p.config as UsagePriceConfig)?.internal_feature_id == + (p.config as UsagePriceConfig)?.internal_feature_id === internal_feature_id && isPrepaidPrice({ price: p }), ); const onDecrease = curPrice?.proration_config?.on_decrease; - const decreaseIsNone = onDecrease == OnDecrease.None; + const decreaseIsNone = onDecrease === OnDecrease.None; if (decreaseIsNone && prevQuantity && quantity < prevQuantity) { console.log( @@ -122,7 +118,7 @@ const filterNoProratePrepaidItems = ({ const onIncrease = curPrice?.proration_config?.on_increase; if ( - onIncrease == OnIncrease.ProrateNextCycle && + onIncrease === OnIncrease.ProrateNextCycle && prevQuantity && quantity > prevQuantity ) { @@ -173,7 +169,7 @@ export const getUpgradeProductPreview = async ({ if (config?.disableTrial) attachParams.freeTrial = null; let freeTrial = attachParams.freeTrial; - let anchor = sub ? sub.billing_cycle_anchor * 1000 : undefined; + const anchor = sub ? sub.billing_cycle_anchor * 1000 : undefined; if ( config?.carryTrial && @@ -194,7 +190,9 @@ export const getUpgradeProductPreview = async ({ anchor, }); - let dueNextCycle = undefined; + let dueNextCycle: + | { line_items: PreviewLineItem[]; due_at: number } + | undefined; if (!isFreeProduct(newProduct.prices)) { const nextCycleAt = getNextCycleAt({ prices: newProduct.prices, @@ -205,31 +203,33 @@ export const getUpgradeProductPreview = async ({ curCusProduct, }); - let nextCycleItems = await getItemsForNewProduct({ + const nextCycleItems = await getItemsForNewProduct({ newProduct, attachParams, logger, withPrepaid, }); - dueNextCycle = { - line_items: nextCycleItems, - due_at: nextCycleAt, - }; + if (nextCycleAt) { + dueNextCycle = { + line_items: nextCycleItems, + due_at: nextCycleAt, + }; + } } let items = [...curPreviewItems, ...newPreviewItems]; for (const item of structuredClone(curPreviewItems)) { - let priceId = item.price_id; - let newItem = newPreviewItems.find((i) => i.price_id == priceId); + const priceId = item.price_id; + const newItem = newPreviewItems.find((i) => i.price_id === priceId); if (!newItem) { continue; } - let newItemAmount = new Decimal(newItem?.amount ?? 0).toDecimalPlaces(2); - let curItemAmount = new Decimal(item.amount ?? 0).toDecimalPlaces(2); + const newItemAmount = new Decimal(newItem?.amount ?? 0).toDecimalPlaces(2); + const curItemAmount = new Decimal(item.amount ?? 0).toDecimalPlaces(2); if (newItemAmount.add(curItemAmount).eq(0)) { items = items.filter((i) => i.price_id !== priceId); @@ -241,7 +241,7 @@ export const getUpgradeProductPreview = async ({ .toDecimalPlaces(2) .toNumber(); - let options = getOptions({ + const options = getOptions({ prodItems: mapToProductItems({ prices: newProduct.prices, entitlements: newProduct.entitlements, @@ -256,10 +256,10 @@ export const getUpgradeProductPreview = async ({ items = items.filter((item) => item.amount !== 0); - if (branch == AttachBranch.UpdatePrepaidQuantity) { - items = items.filter((item) => item.usage_model == UsageModel.Prepaid); + if (branch === AttachBranch.UpdatePrepaidQuantity) { + items = items.filter((item) => item.usage_model === UsageModel.Prepaid); dueNextCycle!.line_items = dueNextCycle!.line_items.filter( - (item) => item.usage_model == UsageModel.Prepaid, + (item) => item.usage_model === UsageModel.Prepaid, ); items = filterNoProratePrepaidItems({ @@ -279,11 +279,20 @@ export const getUpgradeProductPreview = async ({ total: dueTodayAmt, }; - if (branch == AttachBranch.SameCustomEnts) { + if (branch === AttachBranch.SameCustomEnts) { dueToday = undefined; } - if (branch == AttachBranch.NewVersion && dueToday) { + if (branch === AttachBranch.NewVersion && dueToday) { + const curProduct = cusProductToProduct({ cusProduct: curCusProduct }); + const newProduct = attachParamsToProduct({ attachParams }); + + if (isFreeProduct(curProduct.prices) && !isFreeProduct(newProduct.prices)) { + throw new Error( + `Version ${curProduct.version} is free, cannot upgrade to version ${newProduct.version} which has a paid price`, + ); + } + dueToday.line_items = []; dueToday.total = 0; } diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusRewards.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusRewards.ts index f438d4b01..ac5770655 100644 --- a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusRewards.ts +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusRewards.ts @@ -1,16 +1,14 @@ -import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; -import { createStripeCli } from "@/external/stripe/utils.js"; -import { ExtendedRequest } from "@/utils/models/Request.js"; import { - AppEnv, + type AppEnv, CouponDurationType, CusExpand, - FullCustomer, - Organization, + type FullCustomer, + type Organization, RewardType, - Subscription, } from "@autumn/shared"; -import Stripe from "stripe"; +import type Stripe from "stripe"; +import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; export const getCusRewards = async ({ org, @@ -33,14 +31,14 @@ export const getCusRewards = async ({ return undefined; } - let stripeCli = createStripeCli({ + const stripeCli = createStripeCli({ org, env, }); const [stripeCus, stripeSubs] = await Promise.all([ stripeCli.customers.retrieve( - fullCus.processor?.id!, + fullCus.processor?.id, ) as Promise, getStripeSubs({ stripeCli, @@ -49,7 +47,7 @@ export const getCusRewards = async ({ }), ]); - let stripeDiscounts: Stripe.Discount[] = stripeSubs?.flatMap( + const stripeDiscounts: Stripe.Discount[] = stripeSubs?.flatMap( (s) => s.discounts, ) as Stripe.Discount[]; @@ -57,7 +55,7 @@ export const getCusRewards = async ({ stripeDiscounts.push(stripeCus.discount); } - let rewards = { + const rewards = { discounts: stripeDiscounts.map((d) => { let duration_type: CouponDurationType; let duration_value = 0; diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusUpcomingInvoice.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusUpcomingInvoice.ts new file mode 100644 index 000000000..0f72fd212 --- /dev/null +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusUpcomingInvoice.ts @@ -0,0 +1,94 @@ +import { + type AppEnv, + CusExpand, + type FullCustomer, + type Organization, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; +import { lineItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; +import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; +import { stripeDiscountToResponse } from "./stripeDiscountToResponse.js"; + +export const getCusUpcomingInvoice = async ({ + db, + org, + env, + fullCus, + expand, +}: { + db: DrizzleCli; + org: Organization; + env: AppEnv; + fullCus: FullCustomer; + expand: CusExpand[]; +}) => { + if (!expand.includes(CusExpand.UpcomingInvoice)) return undefined; + + const subIds = fullCus.customer_products.flatMap( + (cp) => cp.subscription_ids || [], + ); + + if (subIds.length === 0) return null; + + const stripeCli = createStripeCli({ org, env }); + + const subs = await getStripeSubs({ + stripeCli, + subIds, + }); + + const sub = subs.reduce((acc, sub) => { + const curSubPeriodEnd = getEarliestPeriodEnd({ sub }); + const nextSubPeriodEnd = getEarliestPeriodEnd({ sub }); + return nextSubPeriodEnd < curSubPeriodEnd ? sub : acc; + }, subs[0]); + + const upcomingInvoice = await stripeCli.invoices.createPreview({ + customer: fullCus.processor?.id, + subscription: sub.id, + expand: ["discounts.coupon"], + }); + + const lines = []; + for (const line of upcomingInvoice.lines.data) { + const cusProd = fullCus.customer_products.find((cp) => + lineItemInCusProduct({ cusProduct: cp, lineItem: line }), + ); + lines.push({ + product_id: cusProd?.product.id || null, + description: line.description, + amount: new Decimal(line.amount).div(100).toDecimalPlaces(2).toNumber(), + }); + } + + const stripeDiscounts = upcomingInvoice.discounts.filter( + (d): d is Stripe.Discount => + typeof d === "object" && d !== null && "coupon" in d, + ) as Stripe.Discount[]; + + // Get reward in IDs + + const discounts = stripeDiscounts.map((d) => + stripeDiscountToResponse({ + discount: d, + totalDiscountAmounts: upcomingInvoice.total_discount_amounts || undefined, + }), + ); + + // console.log("lines: ", lines); + // console.log("discounts: ", discounts); + + const res = { + lines, + discounts, + subtotal: upcomingInvoice.subtotal / 100, + total: upcomingInvoice.total / 100, + currency: upcomingInvoice.currency, + }; + + return res; +}; diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/stripeDiscountToResponse.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/stripeDiscountToResponse.ts new file mode 100644 index 000000000..c58f2a5e3 --- /dev/null +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/stripeDiscountToResponse.ts @@ -0,0 +1,58 @@ +import { CouponDurationType, RewardType } from "@autumn/shared"; +import type Stripe from "stripe"; + +const parseStripeCouponDuration = (coupon: Stripe.Coupon) => { + let duration_type: CouponDurationType = CouponDurationType.OneOff; + let duration_value: number = 0; + + if (coupon.duration === "forever") { + duration_type = CouponDurationType.Forever; + } else if (coupon.duration === "once") { + duration_type = CouponDurationType.OneOff; + } else if (coupon.duration === "repeating") { + duration_type = CouponDurationType.Months; + duration_value = coupon.duration_in_months || 0; + } else { + duration_type = CouponDurationType.OneOff; + } + + return { + duration_type, + duration_value, + }; +}; + +export const stripeDiscountToResponse = ({ + discount, + totalDiscountAmounts, +}: { + discount: Stripe.Discount; + totalDiscountAmounts?: Stripe.Invoice.TotalDiscountAmount[]; +}) => { + const d = discount; + + const { duration_type, duration_value } = parseStripeCouponDuration(d.coupon); + + const totalDiscountAmount = totalDiscountAmounts?.find( + (t) => t.discount === d.id, + ); + + return { + id: d.coupon?.id, + name: d.coupon?.name ?? "", + type: d.coupon?.amount_off + ? RewardType.FixedDiscount + : RewardType.PercentageDiscount, + discount_value: d.coupon?.amount_off || d.coupon?.percent_off || 0, + currency: d.coupon?.currency ?? null, + start: d.start ?? null, + end: d.end ?? null, + // subscription_id: d.subscription ?? null, + duration_type, + duration_value, + + total_discount_amount: totalDiscountAmount?.amount + ? totalDiscountAmount.amount / 100 + : null, + }; +}; diff --git a/server/src/internal/customers/cusUtils/getCustomerDetails.ts b/server/src/internal/customers/cusUtils/getCustomerDetails.ts index 118fc465c..1c2f6fe41 100644 --- a/server/src/internal/customers/cusUtils/getCustomerDetails.ts +++ b/server/src/internal/customers/cusUtils/getCustomerDetails.ts @@ -1,33 +1,33 @@ -import { BREAK_API_VERSION } from "@/utils/constants.js"; import { - AppEnv, - CusProductStatus, - FullCusProduct, APIVersion, + type AppEnv, + CusEntResponseSchema, + CusExpand, + CusProductStatus, CusResponseSchema, CustomerResponseSchema, - CusEntResponseSchema, - FeatureType, - Feature, - Organization, - FullCustomer, - CusExpand, - RewardResponse, + cusProductsToCusEnts, + cusProductsToCusPrices, EntityResponseSchema, + type Feature, + FeatureType, + type FullCusProduct, + type FullCustomer, + type Organization, + type RewardResponse, } from "@autumn/shared"; -import { getCusInvoices } from "./cusUtils.js"; - -import { orgToVersion } from "@/utils/versionUtils.js"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { cusProductsToCusEnts, cusProductsToCusPrices } from "@autumn/shared"; - +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js"; -import { getCusBalances } from "./cusFeatureResponseUtils/getCusBalances.js"; +import { BREAK_API_VERSION } from "@/utils/constants.js"; +import { orgToVersion } from "@/utils/versionUtils.js"; import { featuresToObject } from "./cusFeatureResponseUtils/balancesToFeatureResponse.js"; +import { getCusBalances } from "./cusFeatureResponseUtils/getCusBalances.js"; import { processFullCusProducts } from "./cusProductResponseUtils/processFullCusProducts.js"; +import { getCusPaymentMethodRes } from "./cusResponseUtils/getCusPaymentMethodRes.js"; import { getCusReferrals } from "./cusResponseUtils/getCusReferrals.js"; import { getCusRewards } from "./cusResponseUtils/getCusRewards.js"; -import { getCusPaymentMethodRes } from "./cusResponseUtils/getCusPaymentMethodRes.js"; +import { getCusUpcomingInvoice } from "./cusResponseUtils/getCusUpcomingInvoice.js"; +import { getCusInvoices } from "./cusUtils.js"; export const getCustomerDetails = async ({ db, @@ -52,18 +52,18 @@ export const getCustomerDetails = async ({ expand: CusExpand[]; reqApiVersion?: number; }) => { - let apiVersion = orgToVersion({ + const apiVersion = orgToVersion({ org, reqApiVersion, }); - let withRewards = expand.includes(CusExpand.Rewards); + const withRewards = expand.includes(CusExpand.Rewards); - let inStatuses = org.config.include_past_due + const inStatuses = org.config.include_past_due ? [CusProductStatus.Active, CusProductStatus.PastDue] : [CusProductStatus.Active]; - let cusEnts = cusProductsToCusEnts({ cusProducts, inStatuses }) as any; + const cusEnts = cusProductsToCusEnts({ cusProducts, inStatuses }) as any; const balances = await getCusBalances({ cusEntsWithCusProduct: cusEnts, @@ -72,7 +72,7 @@ export const getCustomerDetails = async ({ apiVersion, }); - let subIds = cusProducts.flatMap( + const subIds = cusProducts.flatMap( (cp: FullCusProduct) => cp.subscription_ids || [], ); @@ -87,8 +87,8 @@ export const getCustomerDetails = async ({ if (apiVersion >= APIVersion.v1_1) { let entList: any = balances.map((b) => { - let isBoolean = - features.find((f: Feature) => f.id == b.feature_id)?.type == + const isBoolean = + features.find((f: Feature) => f.id === b.feature_id)?.type === FeatureType.Boolean; if (b.unlimited || isBoolean) { return b; @@ -101,7 +101,7 @@ export const getCustomerDetails = async ({ }); }); - let products: any = [...main, ...addOns]; + const products: any = [...main, ...addOns]; if (apiVersion >= APIVersion.v1_2) { entList = featuresToObject({ @@ -110,9 +110,9 @@ export const getCustomerDetails = async ({ }); } - let withInvoices = expand.includes(CusExpand.Invoices); + const withInvoices = expand.includes(CusExpand.Invoices); - let rewards: RewardResponse | undefined = await getCusRewards({ + const rewards: RewardResponse | undefined = await getCusRewards({ org, env, fullCus: customer, @@ -120,26 +120,35 @@ export const getCustomerDetails = async ({ expand, }); - let referrals = await getCusReferrals({ + const upcomingInvoice = await getCusUpcomingInvoice({ db, - fullCus: customer, - expand, - }); - - let paymentMethod = await getCusPaymentMethodRes({ org, env, fullCus: customer, expand, }); - let cusResponse = { + const referrals = await getCusReferrals({ + db, + fullCus: customer, + expand, + }); + + const paymentMethod = await getCusPaymentMethodRes({ + org, + env, + fullCus: customer, + expand, + }); + + const cusResponse = { ...CusResponseSchema.parse({ ...customer, stripe_id: customer.processor?.id, features: entList, products, // invoices: withInvoices ? invoices : undefined, + invoices: withInvoices ? invoicesToResponse({ invoices: customer.invoices || [], @@ -165,6 +174,7 @@ export const getCustomerDetails = async ({ : undefined, referrals, payment_method: paymentMethod, + upcoming_invoice: upcomingInvoice, }), }; @@ -177,7 +187,7 @@ export const getCustomerDetails = async ({ return cusResponse; } } else { - let withItems = org.config.api_version >= BREAK_API_VERSION; + const withItems = org.config.api_version >= BREAK_API_VERSION; const processedInvoices = await getCusInvoices({ db, diff --git a/server/src/internal/customers/handlers/handleGetCustomer.ts b/server/src/internal/customers/handlers/handleGetCustomer.ts index 748fb7583..88a148061 100644 --- a/server/src/internal/customers/handlers/handleGetCustomer.ts +++ b/server/src/internal/customers/handlers/handleGetCustomer.ts @@ -1,10 +1,10 @@ -import { routeHandler } from "@/utils/routerUtils.js"; import { APIVersion, CusExpand, ErrCode } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -import { getCustomerDetails } from "../cusUtils/getCustomerDetails.js"; -import { parseCusExpand } from "../cusUtils/cusUtils.js"; +import { routeHandler } from "@/utils/routerUtils.js"; import { orgToVersion } from "@/utils/versionUtils.js"; import { getCusWithCache } from "../cusCache/getCusWithCache.js"; +import { parseCusExpand } from "../cusUtils/cusUtils.js"; +import { getCustomerDetails } from "../cusUtils/getCustomerDetails.js"; export const handleGetCustomer = async (req: any, res: any) => routeHandler({ @@ -12,18 +12,18 @@ export const handleGetCustomer = async (req: any, res: any) => res, action: "get customer", handler: async () => { - let customerId = req.params.customer_id; - let { env, db, logtail: logger, org, features } = req; - let { expand } = req.query; + const customerId = req.params.customer_id; + const { env, db, logtail: logger, org, features } = req; + const { expand } = req.query; - let expandArray = parseCusExpand(expand); + const expandArray = parseCusExpand(expand); - let apiVersion = orgToVersion({ + const apiVersion = orgToVersion({ org, reqApiVersion: req.apiVersion, }); - let getInvoices = apiVersion < APIVersion.v1_1; + const getInvoices = apiVersion < APIVersion.v1_1; if (getInvoices) expandArray.push(CusExpand.Invoices); logger.info(`getting customer ${customerId} for org ${org.slug}`); @@ -51,7 +51,7 @@ export const handleGetCustomer = async (req: any, res: any) => return; } - let cusData = await getCustomerDetails({ + const cusData = await getCustomerDetails({ db, customer, org, diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index 204390027..cf1b06646 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -1,54 +1,51 @@ import { - EntitlementWithFeature, - FullProduct, - Organization, - Price, - Feature, BillingInterval, - FreeTrial, - PreviewLineItem, BillingType, + type EntitlementWithFeature, + type Feature, + type FreeTrial, + type FullProduct, getFeatureInvoiceDescription, - UsagePriceConfig, - UsageModel, - AttachConfig, - AttachBranch, - ProrationBehavior, - IntervalConfig, -} from "@autumn/shared"; -import { AttachParams } from "../../customers/cusProducts/AttachParams.js"; -import { - formatPrice, - getBillingType, - getPriceForOverage, - getPriceOptions, -} from "../../products/prices/priceUtils.js"; -import { getPriceEntitlement } from "../../products/prices/priceUtils.js"; -import { - isFixedPrice, - isOneOffPrice, - isPrepaidPrice, + type IntervalConfig, isUsagePrice, -} from "../../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; - -import { newPriceToInvoiceDescription } from "../invoiceFormatUtils.js"; -import { calculateProrationAmount } from "../prorationUtils.js"; -import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js"; -import { toProductItem } from "@autumn/shared"; -import { formatAmount } from "@/utils/formatUtils.js"; -import { formatUnixToDate, notNullish } from "@/utils/genUtils.js"; -import { subtractIntervalForProration } from "../../products/prices/billingIntervalUtils.js"; + type Organization, + type PreviewLineItem, + type Price, + toProductItem, + UsageModel, + type UsagePriceConfig, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import type Stripe from "stripe"; +import { attachParamsToCurCusProduct } from "@/internal/customers/attach/attachUtils/convertAttachParams.js"; +import { getContUseInvoiceItems } from "@/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.js"; +import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js"; import { priceToFeature, priceToUsageModel, } from "@/internal/products/prices/priceUtils/convertPrice.js"; -import { getContUseInvoiceItems } from "@/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.js"; -import Stripe from "stripe"; -import { attachParamsToCurCusProduct } from "@/internal/customers/attach/attachUtils/convertAttachParams.js"; -import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js"; import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js"; -import { Decimal } from "decimal.js"; -import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js"; +import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js"; +import { formatAmount } from "@/utils/formatUtils.js"; +import { formatUnixToDate, notNullish } from "@/utils/genUtils.js"; +import type { AttachParams } from "../../customers/cusProducts/AttachParams.js"; +import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js"; +import { subtractIntervalForProration } from "../../products/prices/billingIntervalUtils.js"; +import { + isFixedPrice, + isOneOffPrice, + isPrepaidPrice, +} from "../../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; + +import { + formatPrice, + getBillingType, + getPriceEntitlement, + getPriceForOverage, + getPriceOptions, +} from "../../products/prices/priceUtils.js"; +import { newPriceToInvoiceDescription } from "../invoiceFormatUtils.js"; +import { calculateProrationAmount } from "../prorationUtils.js"; export const getDefaultPriceStr = ({ org, @@ -94,7 +91,7 @@ export const getProration = ({ intervalCount = intervalCount ?? 1; now = now || Date.now(); - if (interval == BillingInterval.OneOff) return undefined; + if (interval === BillingInterval.OneOff) return undefined; let end = proration?.end; if (!end && anchor) { @@ -220,7 +217,7 @@ export const getItemsForNewProduct = async ({ continue; } - if (billingType == BillingType.UsageInArrear) { + if (billingType === BillingType.UsageInArrear) { items.push({ price: getDefaultPriceStr({ org, price, ent: ent!, features }), description: newPriceToInvoiceDescription({ @@ -236,8 +233,8 @@ export const getItemsForNewProduct = async ({ } if (withPrepaid && isPrepaidPrice({ price })) { - let options = getPriceOptions(price, attachParams.optionsList); - let quantity = notNullish(options?.quantity) ? options?.quantity! : 1; + const options = getPriceOptions(price, attachParams.optionsList); + const quantity = notNullish(options?.quantity) ? options?.quantity! : 1; const quantityWithBillingUnits = new Decimal(quantity).mul( (price.config as UsagePriceConfig).billing_units || 1, @@ -245,14 +242,14 @@ export const getItemsForNewProduct = async ({ // console.log("price", price); // console.log("Quantity", quantity); - let amount = priceToInvoiceAmount({ + const amount = priceToInvoiceAmount({ price, quantity: quantityWithBillingUnits.toNumber(), proration: finalProration, now, }); // console.log("Amount", amount); - let feature = priceToFeature({ + const feature = priceToFeature({ price, features, })!; @@ -281,7 +278,7 @@ export const getItemsForNewProduct = async ({ attachParams, }); - let { newItems } = await getContUseInvoiceItems({ + const { newItems } = await getContUseInvoiceItems({ cusProduct, sub, attachParams, diff --git a/server/src/internal/migrations/migrationRouter.ts b/server/src/internal/migrations/migrationRouter.ts index 08dd0bbd8..fcad47c88 100644 --- a/server/src/internal/migrations/migrationRouter.ts +++ b/server/src/internal/migrations/migrationRouter.ts @@ -1,163 +1,158 @@ -import { ProductService } from "@/internal/products/ProductService.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { - BillingType, - CusProductStatus, - ErrCode, - UsagePriceConfig, -} from "@autumn/shared"; -import { routeHandler } from "@/utils/routerUtils.js"; -import express, { Router } from "express"; -import { constructMigrationJob } from "@/internal/migrations/migrationUtils.js"; +import { BillingType, ErrCode, type UsagePriceConfig } from "@autumn/shared"; +import express, { type Router } from "express"; import { MigrationService } from "@/internal/migrations/MigrationService.js"; -import { JobName } from "@/queue/JobName.js"; -import { addTaskToQueue } from "@/queue/queueUtils.js"; +import { constructMigrationJob } from "@/internal/migrations/migrationUtils.js"; +import { ProductService } from "@/internal/products/ProductService.js"; import { getBillingType, pricesOnlyOneOff, } from "@/internal/products/prices/priceUtils.js"; import { isFreeProduct } from "@/internal/products/productUtils.js"; -import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; +import { JobName } from "@/queue/JobName.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import type { + ExtendedRequest, + ExtendedResponse, +} from "@/utils/models/Request.js"; +import { routeHandler } from "@/utils/routerUtils.js"; import { findPrepaidPrice } from "../products/prices/priceUtils/findPriceUtils.js"; export const migrationRouter: Router = express.Router(); +export const handleMigrate = async ( + req: ExtendedRequest, + res?: ExtendedResponse, +) => { + const { orgId, env, db, features } = req; + + const { from_product_id, from_version, to_product_id, to_version } = req.body; + + const fromProduct = await ProductService.getFull({ + db, + env, + orgId, + idOrInternalId: from_product_id, + version: from_version, + }); + + const toProduct = await ProductService.getFull({ + db, + env, + orgId, + idOrInternalId: to_product_id, + version: to_version, + }); + + if (isFreeProduct(fromProduct.prices) && !isFreeProduct(toProduct.prices)) { + throw new RecaseError({ + message: `Cannot migrate customers from free product to paid product`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + // Check if from product is one off, or to product is one off + if ( + pricesOnlyOneOff(fromProduct.prices) || + pricesOnlyOneOff(toProduct.prices) + ) { + const fromIsOneOff = pricesOnlyOneOff(fromProduct.prices); + const msg = fromIsOneOff + ? `${fromProduct.name} is a one off product, cannot migrate customers on it` + : `${toProduct.name} is a one off product, cannot migrate customers to this product`; + + throw new RecaseError({ + message: msg, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + if (fromProduct.is_add_on || toProduct.is_add_on) { + throw new RecaseError({ + message: `Cannot migrate customers for add on products`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + for (const price of toProduct.prices) { + const billingType = getBillingType(price.config); + if (billingType !== BillingType.UsageInAdvance) continue; + + const config = price.config as UsagePriceConfig; + const internalFeatureId = config.internal_feature_id; + const feature = features.find((f) => f.internal_id === internalFeatureId); + + const prepaidPrice = findPrepaidPrice({ + prices: fromProduct.prices, + internalFeatureId, + }); + + if (!prepaidPrice) { + throw new RecaseError({ + message: `New product has prepaid price for feature ${feature?.name}, but old product does not, can't perform migration`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + } + + if (!isFreeProduct(fromProduct.prices) && isFreeProduct(toProduct.prices)) { + throw new RecaseError({ + message: `Cannot migrate customers from paid product to free product`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + if (isFreeProduct(fromProduct.prices) && !isFreeProduct(toProduct.prices)) { + throw new RecaseError({ + message: `Cannot migrate customers from free product to paid product`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + // 1. Create migration JOB + const migrationJob = constructMigrationJob({ + fromProduct, + toProduct, + }); + + await MigrationService.createJob({ + db, + data: migrationJob, + }); + + if (!fromProduct || !toProduct) { + throw new RecaseError({ + message: `Product ${from_product_id} version ${from_version} or ${to_product_id} version ${to_version} not found`, + code: ErrCode.ProductNotFound, + statusCode: 404, + }); + } + + await addTaskToQueue({ + jobName: JobName.Migration, + payload: { + migrationJobId: migrationJob.id, + }, + }); + + if (res) { + res.status(200).json(migrationJob); + } +}; + migrationRouter.post("", async (req: any, res: any) => { return routeHandler({ req, res, action: "migrate", handler: async (req: ExtendedRequest, res: ExtendedResponse) => { - const { orgId, env, db, features } = req; - - const { from_product_id, from_version, to_product_id, to_version } = - req.body; - - let fromProduct = await ProductService.getFull({ - db, - env, - orgId, - idOrInternalId: from_product_id, - version: from_version, - }); - - let toProduct = await ProductService.getFull({ - db, - env, - orgId, - idOrInternalId: to_product_id, - version: to_version, - }); - - if ( - isFreeProduct(fromProduct.prices) && - !isFreeProduct(toProduct.prices) - ) { - throw new RecaseError({ - message: `Cannot migrate customers from free product to paid product`, - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } - - // Check if from product is one off, or to product is one off - if ( - pricesOnlyOneOff(fromProduct.prices) || - pricesOnlyOneOff(toProduct.prices) - ) { - let fromIsOneOff = pricesOnlyOneOff(fromProduct.prices); - let msg = fromIsOneOff - ? `${fromProduct.name} is a one off product, cannot migrate customers on it` - : `${toProduct.name} is a one off product, cannot migrate customers to this product`; - - throw new RecaseError({ - message: msg, - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } - - if (fromProduct.is_add_on || toProduct.is_add_on) { - throw new RecaseError({ - message: `Cannot migrate customers for add on products`, - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } - - for (const price of toProduct.prices) { - let billingType = getBillingType(price.config); - if (billingType != BillingType.UsageInAdvance) continue; - - let config = price.config as UsagePriceConfig; - let internalFeatureId = config.internal_feature_id; - let feature = features.find((f) => f.internal_id == internalFeatureId)!; - - for (const price of fromProduct.prices) { - let prepaidPrice = findPrepaidPrice({ - prices: fromProduct.prices, - internalFeatureId, - }); - - if (!prepaidPrice) { - throw new RecaseError({ - message: `New product has prepaid price for feature ${feature.name}, but old product does not, can't perform migration`, - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } - } - } - - if ( - !isFreeProduct(fromProduct.prices) && - isFreeProduct(toProduct.prices) - ) { - throw new RecaseError({ - message: `Cannot migrate customers from paid product to free product`, - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } - - if ( - isFreeProduct(fromProduct.prices) && - !isFreeProduct(toProduct.prices) - ) { - throw new RecaseError({ - message: `Cannot migrate customers from free product to paid product`, - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } - - // 1. Create migration JOB - let migrationJob = constructMigrationJob({ - fromProduct, - toProduct, - }); - - await MigrationService.createJob({ - db, - data: migrationJob, - }); - - if (!fromProduct || !toProduct) { - throw new RecaseError({ - message: `Product ${from_product_id} version ${from_version} or ${to_product_id} version ${to_version} not found`, - code: ErrCode.ProductNotFound, - statusCode: 404, - }); - } - - await addTaskToQueue({ - jobName: JobName.Migration, - payload: { - migrationJobId: migrationJob.id, - }, - }); - - res.status(200).json(migrationJob); + await handleMigrate(req, res); }, }); }); diff --git a/server/src/internal/migrations/runRewardMigrationTask.ts b/server/src/internal/migrations/runRewardMigrationTask.ts index 0ce29e31f..c1dc2d787 100644 --- a/server/src/internal/migrations/runRewardMigrationTask.ts +++ b/server/src/internal/migrations/runRewardMigrationTask.ts @@ -1,28 +1,26 @@ import { type AppEnv, + type DiscountConfig, type FixedPriceConfig, - type FullProduct, - type Price, - type UsagePriceConfig, - DiscountConfig, - PriceType, - RewardType, getBillingType, isFixedPrice, isUsagePrice, + type Price, + PriceType, + RewardType, + type UsagePriceConfig, } from "@autumn/shared"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { logger as loggerType } from "@/external/logtail/logtailUtils.js"; +import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.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"; +import { PriceService } from "../products/prices/PriceService.js"; +import { tiersAreSame } from "../products/prices/priceInitUtils.js"; +import { RewardService } from "../rewards/RewardService.js"; // Helper function to check if tier structures match const tiersMatch = (oldTiers: any[], newTiers: any[]): boolean => { @@ -65,8 +63,8 @@ const findMatchingUsagePrice = ( return false; // Match by billing behavior - let newBillingType = getBillingType(newConfig); - let oldBillingType = getBillingType(oldConfig); + const newBillingType = getBillingType(newConfig); + const oldBillingType = getBillingType(oldConfig); if (newBillingType !== oldBillingType) return false; // Optionally match by tier structure @@ -93,7 +91,7 @@ const findBestMatch = (oldPrice: Price, newPrices: Price[]): Price | null => { 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.type === PriceType.Usage ? oldConfig.internal_feature_id === newConfig.internal_feature_id : true) ); diff --git a/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts b/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts index f8af727d1..927f9e8a1 100644 --- a/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts +++ b/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts @@ -2,73 +2,50 @@ import { APIVersion, BillingInterval, BillingType, - FullCusProduct, + type FullCusProduct, OnDecrease, OnIncrease, - Price, - UsagePriceConfig, + type Price, + type UsagePriceConfig, } from "@autumn/shared"; -import { formatPrice, getBillingType } from "../../priceUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; -import Stripe from "stripe"; import { Decimal } from "decimal.js"; +import type Stripe from "stripe"; +import { notNullish } from "@/utils/genUtils.js"; +import { getBillingType } from "../../priceUtils.js"; export const isOneOffPrice = ({ price }: { price: Price }) => { - return price.config.interval == BillingInterval.OneOff; -}; - -export const isUsagePrice = ({ - price, - featureId, -}: { - price: Price; - featureId?: string; -}) => { - let billingType = getBillingType(price.config); - - let isUsage = - billingType == BillingType.UsageInArrear || - billingType == BillingType.InArrearProrated || - billingType == BillingType.UsageInAdvance; - - if (featureId) { - return ( - isUsage && (price.config as UsagePriceConfig).feature_id == featureId - ); - } - - return isUsage; + return price.config.interval === BillingInterval.OneOff; }; export const isArrearPrice = ({ price }: { price?: Price }) => { if (!price) return false; - let billingType = getBillingType(price.config); - return billingType == BillingType.UsageInArrear; + const billingType = getBillingType(price.config); + return billingType === BillingType.UsageInArrear; }; export const isContUsePrice = ({ price }: { price?: Price }) => { if (!price) return false; - let billingType = getBillingType(price.config); - return billingType == BillingType.InArrearProrated; + const billingType = getBillingType(price.config); + return billingType === BillingType.InArrearProrated; }; export const isPrepaidPrice = ({ price }: { price: Price }) => { - let billingType = getBillingType(price.config); - return billingType == BillingType.UsageInAdvance; + const billingType = getBillingType(price.config); + return billingType === BillingType.UsageInAdvance; }; export const isPayPerUse = ({ price }: { price: Price }) => { - let billingType = getBillingType(price.config); + const billingType = getBillingType(price.config); return ( - billingType == BillingType.UsageInArrear || - billingType == BillingType.InArrearProrated + billingType === BillingType.UsageInArrear || + billingType === BillingType.InArrearProrated ); }; export const isFixedPrice = ({ price }: { price: Price }) => { - let billingType = getBillingType(price.config); + const billingType = getBillingType(price.config); return ( - billingType == BillingType.FixedCycle || billingType == BillingType.OneOff + billingType === BillingType.FixedCycle || billingType === BillingType.OneOff ); }; @@ -80,8 +57,8 @@ export const hasPrepaidPrice = ({ excludeOneOff?: boolean; }) => { return prices.some((price) => { - let isUsage = getBillingType(price.config) == BillingType.UsageInAdvance; - let isOneOff = price.config.interval == BillingInterval.OneOff; + const isUsage = getBillingType(price.config) === BillingType.UsageInAdvance; + const isOneOff = price.config.interval === BillingInterval.OneOff; return isUsage && (excludeOneOff ? !isOneOff : true); }); @@ -97,8 +74,8 @@ export const isV4Usage = ({ const billingType = getBillingType(price.config); return ( - billingType == BillingType.UsageInArrear && - (cusProduct.api_version == APIVersion.v1_4 || + billingType === BillingType.UsageInArrear && + (cusProduct.api_version === APIVersion.v1_4 || notNullish(cusProduct.internal_entity_id)) ); }; @@ -143,10 +120,10 @@ export const roundUsage = ({ price: Price; pos?: boolean; }) => { - let config = price.config as UsagePriceConfig; - let billingUnits = config.billing_units || 1; + const config = price.config as UsagePriceConfig; + const billingUnits = config.billing_units || 1; - let rounded = new Decimal(usage) + const rounded = new Decimal(usage) .div(billingUnits) .ceil() .mul(billingUnits) diff --git a/server/src/internal/products/productUtils/sortProductUtils.ts b/server/src/internal/products/productUtils/sortProductUtils.ts index 4302b8ffc..b2e506dff 100644 --- a/server/src/internal/products/productUtils/sortProductUtils.ts +++ b/server/src/internal/products/productUtils/sortProductUtils.ts @@ -1,4 +1,4 @@ -import { FullProduct, Product, ProductV2 } from "@autumn/shared"; +import type { FullProduct } from "@autumn/shared"; import { isProductUpgrade } from "../productUtils.js"; export const sortProductsByPrice = ({ @@ -7,7 +7,7 @@ export const sortProductsByPrice = ({ products: FullProduct[]; }) => { products.sort((a, b) => { - let isUpgradeA = isProductUpgrade({ + const isUpgradeA = isProductUpgrade({ prices1: a.prices, prices2: b.prices, usageAlwaysUpgrade: false, diff --git a/server/src/internal/rewards/RewardService.ts b/server/src/internal/rewards/RewardService.ts index fe33b2ce2..f474b1eab 100644 --- a/server/src/internal/rewards/RewardService.ts +++ b/server/src/internal/rewards/RewardService.ts @@ -2,8 +2,8 @@ import { type AppEnv, ErrCode, type Reward, + type RewardType, rewards, - RewardType, } from "@autumn/shared"; import { and, desc, eq, inArray, or, sql } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle.js"; @@ -39,6 +39,26 @@ export class RewardService { return result as Reward; } + static async getInIds({ + db, + ids, + orgId, + env, + }: { + db: DrizzleCli; + ids: string[]; + orgId: string; + env: AppEnv; + }) { + return (await db.query.rewards.findMany({ + where: and( + inArray(rewards.id, ids), + eq(rewards.org_id, orgId), + eq(rewards.env, env), + ), + })) as Reward[]; + } + static async getByIdOrCode({ db, codes, diff --git a/server/src/utils/importUtils/addProductFromSubs.ts b/server/src/utils/importUtils/addProductFromSubs.ts index 406d94d88..c3f571f94 100644 --- a/server/src/utils/importUtils/addProductFromSubs.ts +++ b/server/src/utils/importUtils/addProductFromSubs.ts @@ -1,26 +1,23 @@ -import Stripe from "stripe"; +import { + BillingInterval, + type CusProductStatus, + type EntitlementWithFeature, + type FullCustomer, + type FullProduct, + isUsagePrice, + type Price, + type UsagePriceConfig, +} from "@autumn/shared"; +import type Stripe from "stripe"; +import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { stripeToAutumnSubStatus } from "@/external/stripe/stripeSubUtils.js"; +import { subToAutumnInterval } from "@/external/stripe/utils.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; -import { isFreeProduct } from "@/internal/products/productUtils.js"; +import { PriceService } from "@/internal/products/prices/PriceService.js"; import { SubService } from "@/internal/subscriptions/SubService.js"; import { constructSub } from "@/internal/subscriptions/subUtils.js"; -import { - FullCustomer, - FullProduct, - Price, - EntitlementWithFeature, - CusProductStatus, - UsagePriceConfig, - BillingInterval, -} from "@autumn/shared"; - import { notNullish } from "../genUtils.js"; -import { ExtendedRequest } from "../models/Request.js"; -import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; -import { subToAutumnInterval } from "@/external/stripe/utils.js"; -import { prices as priceTable } from "@autumn/shared"; -import { PriceService } from "@/internal/products/prices/PriceService.js"; -import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; +import type { ExtendedRequest } from "../models/Request.js"; export const addProductFromSubs = async ({ req, @@ -48,17 +45,17 @@ export const addProductFromSubs = async ({ const cusProducts = autumnCus.customer_products; const entity = autumnCus.entity; - let mainCusProduct = cusProducts.find( + const mainCusProduct = cusProducts.find( (cp) => !cp.product.is_add_on && - cp.product_id == autumnProduct.id && + cp.product_id === autumnProduct.id && (notNullish(entity) - ? cp.internal_entity_id == entity!.internal_id + ? cp.internal_entity_id === entity!.internal_id : true), ); if (mainCusProduct && !force) { - let prices = mainCusProduct.customer_prices.map((cp) => cp.price); + const prices = mainCusProduct.customer_prices.map((cp) => cp.price); // let isFree = isFreeProduct(prices); if (mainCusProduct) { @@ -74,12 +71,12 @@ export const addProductFromSubs = async ({ } // Handle if trialing - let trialEndsAt = sub?.trial_end ? sub.trial_end * 1000 : null; + const trialEndsAt = sub?.trial_end ? sub.trial_end * 1000 : null; // throw new Error("test"); // 1. Insert custom prices... - let customPrices = prices?.filter((p) => p.is_custom); + const customPrices = prices?.filter((p) => p.is_custom); if (customPrices && customPrices.length > 0) { await PriceService.upsert({ db, @@ -91,7 +88,7 @@ export const addProductFromSubs = async ({ sub, }); - let newCusProduct = await createFullCusProduct({ + const newCusProduct = await createFullCusProduct({ db, attachParams: { replaceables: [], @@ -132,16 +129,16 @@ export const addProductFromSubs = async ({ if (sub) { // Create sub - let usageFeatures = autumnProduct.prices + const usageFeatures = autumnProduct.prices .filter((p) => isUsagePrice({ price: p })) .map((p) => (p.config as UsagePriceConfig).internal_feature_id); - let subFromDb = await SubService.getInStripeIds({ + const subFromDb = await SubService.getInStripeIds({ db, ids: [sub.id], }); - let subInterval = subToAutumnInterval(sub); + const subInterval = subToAutumnInterval(sub); if (subFromDb.length === 0) { await SubService.createSub({ @@ -149,7 +146,7 @@ export const addProductFromSubs = async ({ sub: constructSub({ stripeId: sub.id, usageFeatures: - subInterval.interval == BillingInterval.Month ? usageFeatures : [], + subInterval.interval === BillingInterval.Month ? usageFeatures : [], orgId: org.id, env, currentPeriodStart: start, diff --git a/server/tests/utils/productUtils.ts b/server/tests/utils/productUtils.ts index 287de17c2..340757949 100644 --- a/server/tests/utils/productUtils.ts +++ b/server/tests/utils/productUtils.ts @@ -1,8 +1,7 @@ -import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { type AppEnv, type CreateReward, isUsagePrice } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { AutumnInt } from "@/external/autumn/autumnCli.js"; import { ProductService } from "@/internal/products/ProductService.js"; -import { AppEnv, CreateReward, Product, ProductV2 } from "@autumn/shared"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; export const createProduct = async ({ db, @@ -43,7 +42,7 @@ export const createProduct = async ({ await Promise.all(batchDelete); } catch (error) {} - let clone = structuredClone(product); + const clone = structuredClone(product); if (typeof clone.items === "object") { clone.items = Object.values(clone.items); } @@ -106,14 +105,14 @@ export const createReward = async ({ productId: string; onlyUsage?: boolean; }) => { - let fullProduct = await ProductService.getFull({ + const fullProduct = await ProductService.getFull({ db, orgId, env, idOrInternalId: productId!, }); - let usagePrices = fullProduct?.prices.filter((price) => + const usagePrices = fullProduct?.prices.filter((price) => isUsagePrice({ price }), ); diff --git a/shared/models/attachModels/checkoutModels.ts b/shared/models/attachModels/checkoutModels.ts index 9976904e7..b5bf0d404 100644 --- a/shared/models/attachModels/checkoutModels.ts +++ b/shared/models/attachModels/checkoutModels.ts @@ -1,8 +1,7 @@ import { z } from "zod"; -import { AttachScenario } from "../checkModels/checkPreviewModels.js"; +import { FeatureOptionsSchema } from "../cusProductModels/cusProductModels.js"; import { ProductItemResponseSchema } from "../productV2Models/productItemModels/prodItemResponseModels.js"; import { ProductResponseSchema } from "../productV2Models/productResponseModels.js"; -import { FeatureOptionsSchema } from "../cusProductModels/cusProductModels.js"; export const CheckoutLineSchema = z.object({ description: z.string(), diff --git a/shared/models/cusModels/cusExpand.ts b/shared/models/cusModels/cusExpand.ts index eb883f61a..3495026e4 100644 --- a/shared/models/cusModels/cusExpand.ts +++ b/shared/models/cusModels/cusExpand.ts @@ -5,4 +5,5 @@ export enum CusExpand { Entities = "entities", Referrals = "referrals", PaymentMethod = "payment_method", + UpcomingInvoice = "upcoming_invoice", } diff --git a/shared/models/cusModels/cusResModels/upcomingInvoiceResponse.ts b/shared/models/cusModels/cusResModels/upcomingInvoiceResponse.ts new file mode 100644 index 000000000..1dfe31127 --- /dev/null +++ b/shared/models/cusModels/cusResModels/upcomingInvoiceResponse.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { DiscountResponseSchema } from "../../rewardModels/rewardModels/rewardResponseModels.js"; + +export const UpcomingInvoiceResponseSchema = z.object({ + lines: z.array( + z.object({ + product_id: z.string().nullish(), + description: z.string(), + amount: z.number(), + }), + ), + discounts: z.array(DiscountResponseSchema), + subtotal: z.number(), + total: z.number(), + currency: z.string(), +}); + +export type UpcomingInvoiceResponse = z.infer< + typeof UpcomingInvoiceResponseSchema +>; diff --git a/shared/models/cusModels/cusResponseModels.ts b/shared/models/cusModels/cusResponseModels.ts index 9a5b1b39c..119ede667 100644 --- a/shared/models/cusModels/cusResponseModels.ts +++ b/shared/models/cusModels/cusResponseModels.ts @@ -3,6 +3,7 @@ import { AppEnv } from "../genModels/genEnums.js"; import { RewardResponseSchema } from "../rewardModels/rewardModels/rewardResponseModels.js"; import { CusProductResponseSchema } from "./cusResModels/cusProductResponse.js"; import { CusReferralResponseSchema } from "./cusResModels/cusReferralsResponse.js"; +import { UpcomingInvoiceResponseSchema } from "./cusResModels/upcomingInvoiceResponse.js"; import { EntityResponseSchema } from "./entityModels/entityResModels.js"; import { InvoiceResponseSchema } from "./invoiceModels/invoiceResponseModels.js"; @@ -34,6 +35,7 @@ export const CusResponseSchema = z.object({ entities: z.array(EntityResponseSchema).optional(), referrals: z.array(CusReferralResponseSchema).optional(), payment_method: z.any().nullish(), + upcoming_invoice: UpcomingInvoiceResponseSchema.nullish(), }); export type CusResponse = z.infer; diff --git a/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts b/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts index 1b17f1299..6494fbc82 100644 --- a/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts +++ b/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts @@ -6,8 +6,14 @@ export const FixedPriceConfigSchema = z.object({ amount: z.number().min(0), interval: z.nativeEnum(BillingInterval), interval_count: z.number().nullish(), + + // Usage price fields + billing_units: z.number().nullish(), stripe_price_id: z.string().nullish(), + stripe_empty_price_id: z.string().nullish(), stripe_product_id: z.null().or(z.undefined()), + feature_id: z.null().or(z.undefined()), + internal_feature_id: z.null().or(z.undefined()), }); export type FixedPriceConfig = z.infer; diff --git a/shared/models/rewardModels/rewardModels/rewardResponseModels.ts b/shared/models/rewardModels/rewardModels/rewardResponseModels.ts index b47a62511..36c3d3849 100644 --- a/shared/models/rewardModels/rewardModels/rewardResponseModels.ts +++ b/shared/models/rewardModels/rewardModels/rewardResponseModels.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { RewardType } from "./rewardEnums.js"; -import { CouponDurationType } from "./rewardEnums.js"; +import { CouponDurationType, RewardType } from "./rewardEnums.js"; export const DiscountResponseSchema = z.object({ id: z.string(), // either from Autumn or Stripe @@ -17,7 +16,8 @@ export const DiscountResponseSchema = z.object({ start: z.number().nullish(), end: z.number().nullish(), - subscription_id: z.string().nullable(), + subscription_id: z.string().nullish(), + total_discount_amount: z.number().nullish(), }); export const RewardResponseSchema = z.object({ diff --git a/shared/models/rewardModels/rewardProgramModels/rewardProgramModels.ts b/shared/models/rewardModels/rewardProgramModels/rewardProgramModels.ts index f6f867155..b8f80a193 100644 --- a/shared/models/rewardModels/rewardProgramModels/rewardProgramModels.ts +++ b/shared/models/rewardModels/rewardProgramModels/rewardProgramModels.ts @@ -1,7 +1,6 @@ import { z } from "zod"; -import { Reward } from "../rewardModels/rewardModels.js"; -import { RewardReceivedBy } from "./rewardProgramEnums.js"; -import { RewardTriggerEvent } from "./rewardProgramEnums.js"; +import type { Reward } from "../rewardModels/rewardModels.js"; +import { RewardReceivedBy, RewardTriggerEvent } from "./rewardProgramEnums.js"; export const RewardProgram = z.object({ internal_id: z.string(), @@ -33,6 +32,15 @@ export const CreateRewardProgram = z.object({ received_by: z.nativeEnum(RewardReceivedBy), }); +export const UpdateRewardProgram = z.object({ + when: z.nativeEnum(RewardTriggerEvent), + product_ids: z.array(z.string()).optional(), + exclude_trial: z.boolean().optional(), + internal_reward_id: z.string(), + max_redemptions: z.number().optional(), + received_by: z.nativeEnum(RewardReceivedBy), +}); + export type RewardProgram = z.infer; export type CreateRewardProgram = z.infer; diff --git a/shared/utils/productUtils/priceUtils.ts b/shared/utils/productUtils/priceUtils.ts index 8cfd4d375..949bce0a8 100644 --- a/shared/utils/productUtils/priceUtils.ts +++ b/shared/utils/productUtils/priceUtils.ts @@ -1,44 +1,44 @@ -import Stripe from "stripe"; import { Decimal } from "decimal.js"; -import { notNullish } from "../utils.js"; -import { FixedPriceConfig } from "../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; +import type Stripe from "stripe"; +import { APIVersion } from "../../enums/APIVersion.js"; +import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import type { FixedPriceConfig } from "../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; import { BillWhen, - UsagePriceConfig, + type UsagePriceConfig, } from "../../models/productModels/priceModels/priceConfig/usagePriceConfig.js"; import { BillingInterval, BillingType, PriceType, } from "../../models/productModels/priceModels/priceEnums.js"; -import { Price } from "../../models/productModels/priceModels/priceModels.js"; -import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import type { Price } from "../../models/productModels/priceModels/priceModels.js"; import { OnDecrease, OnIncrease, } from "../../models/productV2Models/productItemModels/productItemEnums.js"; -import { APIVersion } from "../../enums/APIVersion.js"; +import { notNullish } from "../utils.js"; export const getBillingType = (config: FixedPriceConfig | UsagePriceConfig) => { // 1. Fixed cycle / one off if ( - config.type == PriceType.Fixed && - config.interval == BillingInterval.OneOff + config.type === PriceType.Fixed && + config.interval === BillingInterval.OneOff ) { return BillingType.OneOff; - } else if (config.type == PriceType.Fixed) { + } else if (config.type === PriceType.Fixed) { return BillingType.FixedCycle; } // 2. Prepaid - let usageConfig = config as UsagePriceConfig; + const usageConfig = config as UsagePriceConfig; if ( - usageConfig.bill_when == BillWhen.InAdvance || - usageConfig.bill_when == BillWhen.StartOfPeriod + usageConfig.bill_when === BillWhen.InAdvance || + usageConfig.bill_when === BillWhen.StartOfPeriod ) { return BillingType.UsageInAdvance; - } else if (usageConfig.bill_when == BillWhen.EndOfPeriod) { + } else if (usageConfig.bill_when === BillWhen.EndOfPeriod) { if (usageConfig.should_prorate) { return BillingType.InArrearProrated; } @@ -49,7 +49,7 @@ export const getBillingType = (config: FixedPriceConfig | UsagePriceConfig) => { }; export const isOneOffPrice = ({ price }: { price: Price }) => { - return price.config.interval == BillingInterval.OneOff; + return price.config.interval === BillingInterval.OneOff; }; export const isUsagePrice = ({ @@ -59,40 +59,38 @@ export const isUsagePrice = ({ price: Price; featureId?: string; }) => { - let billingType = getBillingType(price.config); + const billingType = getBillingType(price.config); - let isUsage = - billingType == BillingType.UsageInArrear || - billingType == BillingType.InArrearProrated || - billingType == BillingType.UsageInAdvance; + const isUsage = + billingType === BillingType.UsageInArrear || + billingType === BillingType.InArrearProrated || + billingType === BillingType.UsageInAdvance; if (featureId) { - return ( - isUsage && (price.config as UsagePriceConfig).feature_id == featureId - ); + return isUsage && price.config.feature_id === featureId; } return isUsage; }; export const isPrepaidPrice = ({ price }: { price: Price }) => { - let billingType = getBillingType(price.config); - return billingType == BillingType.UsageInAdvance; + const billingType = getBillingType(price.config); + return billingType === BillingType.UsageInAdvance; }; export const isPayPerUse = ({ price }: { price: Price }) => { - let billingType = getBillingType(price.config); + const billingType = getBillingType(price.config); return ( - billingType == BillingType.UsageInArrear || - billingType == BillingType.InArrearProrated + billingType === BillingType.UsageInArrear || + billingType === BillingType.InArrearProrated ); }; export const isFixedPrice = ({ price }: { price: Price }) => { - let billingType = getBillingType(price.config); + const billingType = getBillingType(price.config); return ( - billingType == BillingType.FixedCycle || billingType == BillingType.OneOff + billingType === BillingType.FixedCycle || billingType === BillingType.OneOff ); }; @@ -104,8 +102,8 @@ export const hasPrepaidPrice = ({ excludeOneOff?: boolean; }) => { return prices.some((price) => { - let isUsage = getBillingType(price.config) == BillingType.UsageInAdvance; - let isOneOff = price.config.interval == BillingInterval.OneOff; + const isUsage = getBillingType(price.config) === BillingType.UsageInAdvance; + const isOneOff = price.config.interval === BillingInterval.OneOff; return isUsage && (excludeOneOff ? !isOneOff : true); }); }; @@ -120,8 +118,8 @@ export const isV4Usage = ({ const billingType = getBillingType(price.config); return ( - billingType == BillingType.UsageInArrear && - (cusProduct.api_version == APIVersion.v1_4 || + billingType === BillingType.UsageInArrear && + (cusProduct.api_version === APIVersion.v1_4 || notNullish(cusProduct.internal_entity_id)) ); }; @@ -166,10 +164,10 @@ export const roundUsage = ({ price: Price; pos?: boolean; }) => { - let config = price.config as UsagePriceConfig; - let billingUnits = config.billing_units || 1; + const config = price.config as UsagePriceConfig; + const billingUnits = config.billing_units || 1; - let rounded = new Decimal(usage) + const rounded = new Decimal(usage) .div(billingUnits) .ceil() .mul(billingUnits) diff --git a/shared/utils/rewardUtils/rewardMigrationUtils.ts b/shared/utils/rewardUtils/rewardMigrationUtils.ts index e65efd536..f65fb6c31 100644 --- a/shared/utils/rewardUtils/rewardMigrationUtils.ts +++ b/shared/utils/rewardUtils/rewardMigrationUtils.ts @@ -4,9 +4,13 @@ import type { Reward, RewardType, UsagePriceConfig, + UsageTier, } from "../../index.js"; -import type { UsageTier } from "../../models/productModels/priceModels/priceConfig/usagePriceConfig.js"; -import { isFixedPrice, isUsagePrice } from "../productUtils/priceUtils.js"; +import { + getBillingType, + isFixedPrice, + isUsagePrice, +} from "../productUtils/priceUtils.js"; // Helper function to check if tier structures match const tiersMatch = (oldTiers: UsageTier[], newTiers: UsageTier[]): boolean => { @@ -42,6 +46,8 @@ const findMatchingUsagePrice = ( return ( candidates.find((candidate) => { + if (!isUsagePrice({ price: candidate })) return false; + const newConfig = candidate.config as UsagePriceConfig; // Match by feature @@ -50,8 +56,9 @@ const findMatchingUsagePrice = ( return false; // Match by billing behavior - if (newConfig.bill_when !== oldConfig.bill_when) return false; - if (newConfig.should_prorate !== oldConfig.should_prorate) return false; + const newBilingType = getBillingType(newConfig); + const oldBilingType = getBillingType(oldConfig); + if (newBilingType !== oldBilingType) return false; // Optionally match by tier structure if (!tiersMatch(oldConfig.usage_tiers, newConfig.usage_tiers)) diff --git a/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx b/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx index 8b911111c..82c61c662 100644 --- a/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx +++ b/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx @@ -51,6 +51,7 @@ export const RewardProgramConfig = ({
Program ID setRewardProgram({ ...rewardProgram, id: e.target.value }) diff --git a/vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx b/vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx index e7bc74cd1..a5d83fd91 100644 --- a/vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx +++ b/vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx @@ -1,12 +1,13 @@ // import { useProductsContext } from "../ProductsContext"; + +import { type RewardProgram, RewardTriggerEvent } from "@autumn/shared"; import { useState } from "react"; -import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; -import { RewardProgram, RewardTriggerEvent } from "@autumn/shared"; -import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; +import { AdminHover } from "@/components/general/AdminHover"; // import { RewardProgramRowToolbar } from "./RewardProgramRowToolbar"; import { Item, Row } from "@/components/general/TableGrid"; -import { AdminHover } from "@/components/general/AdminHover"; import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery"; +import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; +import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; import { RewardProgramRowToolbar } from "./RewardProgramRowToolbar"; import UpdateRewardProgram from "./UpdateRewardPrograms"; @@ -58,7 +59,11 @@ export const RewardProgramsTable = () => { - {rewardProgram.when} + + {rewardProgram.when === RewardTriggerEvent.CustomerCreation + ? "Customer Redemption" + : keyToTitle(rewardProgram.when)} +
@@ -70,9 +75,9 @@ export const RewardProgramsTable = () => {
- {rewardProgram.when == RewardTriggerEvent.CustomerCreation + {rewardProgram.when === RewardTriggerEvent.CustomerCreation ? "Sign Up" - : rewardProgram.when == RewardTriggerEvent.Checkout + : rewardProgram.when === RewardTriggerEvent.Checkout ? "Checkout" : keyToTitle(rewardProgram.when)}