diff --git a/server/src/external/stripe/stripeSubUtils.ts b/server/src/external/stripe/stripeSubUtils.ts index 81010402f..825aecad9 100644 --- a/server/src/external/stripe/stripeSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils.ts @@ -1,19 +1,22 @@ -import Stripe from "stripe"; import { - BillingInterval, + type BillingInterval, CusProductStatus, - Feature, - FullCusProduct, - Organization, + type Feature, + type FullCusProduct, + type Organization, ProrationBehavior, - UsagePriceConfig, + type 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 +55,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 +131,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 +142,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,10 +180,10 @@ 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 = []; for (const item of stripeSub.items.data) { if (item.price.product == product.processor?.id) { subItems.push(item); @@ -187,7 +198,7 @@ export const getSubItemsForCusProduct = async ({ subItems.push(item); } } - let otherSubItems = stripeSub.items.data.filter( + const otherSubItems = stripeSub.items.data.filter( (item) => !subItems.some((i) => i.id == item.id), ); @@ -244,7 +255,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 +298,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/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/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/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/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/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/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/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)}