diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index 76b79f6e9..04abfedcb 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -20,6 +20,7 @@ import { analyticsMiddleware } from "@/middleware/analyticsMiddleware.js"; import rewardRouter from "./rewards/rewardRouter.js"; import expireRouter from "../customers/expire/expireRouter.js"; +import { handleSetupPayment } from "../customers/attach/handleSetupPayment.js"; const apiRouter: Router = Router(); @@ -49,8 +50,8 @@ apiRouter.use("/attach", attachRouter); apiRouter.use("/cancel", expireRouter); apiRouter.use("/entitled", checkRouter); apiRouter.use("/check", checkRouter); - apiRouter.use("/events", eventsRouter); apiRouter.use("/track", eventsRouter); +apiRouter.post("/setup_payment", handleSetupPayment); export { apiRouter }; diff --git a/server/src/internal/api/entities/getEntityUtils.ts b/server/src/internal/api/entities/getEntityUtils.ts index 918b81a8a..c5fc4174f 100644 --- a/server/src/internal/api/entities/getEntityUtils.ts +++ b/server/src/internal/api/entities/getEntityUtils.ts @@ -1,12 +1,13 @@ import type { DrizzleCli } from "@/db/initDrizzle.js"; import { CusService } from "@/internal/customers/CusService.js"; -import { getCusProductsResponse } from "@/internal/customers/cusUtils/cusResponseUtils.js"; -import { getCusFeaturesResponse } from "@/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusFeaturesResponse.js"; +import { getCusFeaturesResponse } from "@/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.js"; +import { processFullCusProducts } from "@/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.js"; import RecaseError from "@/utils/errorUtils.js"; import { nullish } from "@/utils/genUtils.js"; import { type AppEnv, + Feature, CusProductStatus, type Entity, EntityExpand, @@ -15,6 +16,7 @@ import { type FullCusProduct, type Organization, type Subscription, + CusProductResponse, } from "@autumn/shared"; export const getEntityResponse = async ({ @@ -27,6 +29,7 @@ export const getEntityResponse = async ({ entityId, withAutumnId = false, apiVersion, + features, }: { db: DrizzleCli; entityIds: string[]; @@ -37,6 +40,7 @@ export const getEntityResponse = async ({ entityId?: string; withAutumnId?: boolean; apiVersion: number; + features: Feature[]; }) => { let customer = await CusService.getFull({ db, @@ -81,15 +85,17 @@ export const getEntityResponse = async ({ ), ); - let products = await getCusProductsResponse({ - cusProducts: entityCusProducts, + let { main, addOns } = await processFullCusProducts({ + fullCusProducts: entityCusProducts, entities: customer.entities, subs: entitySubs, org, apiVersion, + features, }); + let products: CusProductResponse[] = [...main, ...addOns]; - let features = await getCusFeaturesResponse({ + let cusFeatures = await getCusFeaturesResponse({ cusProducts: entityCusProducts, org, entity, @@ -105,7 +111,7 @@ export const getEntityResponse = async ({ customer_id: customerId, env, products, - features, + features: cusFeatures, }); } diff --git a/server/src/internal/api/entities/handlers/handleGetEntity.ts b/server/src/internal/api/entities/handlers/handleGetEntity.ts index 4c0a72562..9cecf9919 100644 --- a/server/src/internal/api/entities/handlers/handleGetEntity.ts +++ b/server/src/internal/api/entities/handlers/handleGetEntity.ts @@ -16,7 +16,7 @@ export const handleGetEntity = async (req: any, res: any) => const customerId = req.params.customer_id as string; const expand = parseEntityExpand(req.query.expand); - let { orgId, env, db, logtail: logger } = req; + let { orgId, env, db, logger, features } = req; let org = await OrgService.getFromReq(req); let apiVersion = orgToVersion({ @@ -35,6 +35,7 @@ export const handleGetEntity = async (req: any, res: any) => expand, entityId, apiVersion, + features, }); // const end = performance.now(); // logger.info(`getEntityResponse took ${(end - start).toFixed(2)}ms`); diff --git a/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts b/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts index a6998004f..a4eaf4bea 100644 --- a/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts +++ b/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts @@ -3,8 +3,8 @@ import { getUnlimitedAndUsageAllowed, } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { cusEntMatchesFeature } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; -import { getCusBalances } from "@/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusBalances.js"; -import { balancesToFeatureResponse } from "@/internal/customers/cusProducts/cusEnts/cusFeatureUtils/balancesToFeatureResponse.js"; +import { getCusBalances } from "@/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.js"; +import { balancesToFeatureResponse } from "@/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.js"; import { CheckResponseSchema, Feature, diff --git a/server/src/internal/customers/attach/handleSetupPayment.ts b/server/src/internal/customers/attach/handleSetupPayment.ts new file mode 100644 index 000000000..cd9380fb0 --- /dev/null +++ b/server/src/internal/customers/attach/handleSetupPayment.ts @@ -0,0 +1,47 @@ +import { routeHandler } from "@/utils/routerUtils.js"; +import { getOrCreateCustomer } from "../cusUtils/getOrCreateCustomer.js"; +import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; +import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; + +export const handleSetupPayment = async (req: any, res: any) => + routeHandler({ + req, + res, + action: "setup_payment", + handler: async (req: ExtendedRequest, res: any) => { + // 1. Get the customer + const { db, env, org } = req; + + let { customer_id, customer_data, success_url, checkout_session_params } = + req.body; + + let customer = await getOrCreateCustomer({ + req, + customerId: customer_id, + customerData: customer_data as any, + }); + + await createStripeCusIfNotExists({ + db, + org, + env, + customer, + logger: req.logger, + }); + + const stripeCli = createStripeCli({ org, env }); + const session = await stripeCli.checkout.sessions.create({ + customer: customer.processor?.id, + mode: "setup", + success_url: success_url || org.stripe_config?.success_url, + currency: org.default_currency || "usd", + ...(checkout_session_params as any), + }); + + return res.json({ + customer_id: customer.id, + url: session.url, + }); + }, + }); diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/balancesToFeatureResponse.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts similarity index 100% rename from server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/balancesToFeatureResponse.ts rename to server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusBalances.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts similarity index 99% rename from server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusBalances.ts rename to server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts index ffa9da512..dddc6f526 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusBalances.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts @@ -19,7 +19,7 @@ import { getRelatedCusPrice, getResetBalance, getUnlimitedAndUsageAllowed, -} from "../cusEntUtils.js"; +} from "../../cusProducts/cusEnts/cusEntUtils.js"; export interface CusFeatureBalance { feature_id: string; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusFeaturesResponse.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.ts similarity index 91% rename from server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusFeaturesResponse.ts rename to server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.ts index 34f25e3b1..a77791f87 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusFeaturesResponse.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.ts @@ -3,7 +3,7 @@ import { FullCusProduct, Organization, Entity } from "@autumn/shared"; import { cusProductsToCusEnts, cusProductsToCusPrices, -} from "../../cusProductUtils/convertCusProduct.js"; +} from "../../cusProducts/cusProductUtils/convertCusProduct.js"; import { getCusBalances } from "./getCusBalances.js"; export const getCusFeaturesResponse = async ({ diff --git a/server/src/internal/customers/cusUtils/cusProductResponseUtils/getCusProductRepsonse.ts b/server/src/internal/customers/cusUtils/cusProductResponseUtils/getCusProductRepsonse.ts new file mode 100644 index 000000000..17254159a --- /dev/null +++ b/server/src/internal/customers/cusUtils/cusProductResponseUtils/getCusProductRepsonse.ts @@ -0,0 +1,200 @@ +import { + getPriceOptions, + getUsageTier, +} from "@/internal/products/prices/priceUtils.js"; +import { BREAK_API_VERSION } from "@/utils/constants.js"; +import { notNullish } from "@/utils/genUtils.js"; +import { + FullCusProduct, + Organization, + Subscription, + PriceType, + FixedPriceConfig, + UsagePriceConfig, + TierInfinite, + APIVersion, + CusProductResponseSchema, + CusProductStatus, + Entity, + Feature, +} from "@autumn/shared"; + +import Stripe from "stripe"; +import { getRelatedCusEnt } from "../../cusProducts/cusPrices/cusPriceUtils.js"; +import { fullCusProductToProduct } from "../../cusProducts/cusProductUtils.js"; +import { + getProductItemResponse, + getProductResponse, +} from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; + +export const getCusProductResponse = async ({ + cusProduct, + subs, + org, + entities = [], + apiVersion, + features, +}: { + cusProduct: FullCusProduct; + org: Organization; + subs?: (Stripe.Subscription | Subscription)[]; + entities?: Entity[]; + apiVersion: number; + features: Feature[]; +}) => { + // Process prices + + const prices = cusProduct.customer_prices.map((cp) => { + let price = cp.price; + + if (price.config?.type == PriceType.Fixed) { + let config = price.config as FixedPriceConfig; + return { + amount: config.amount, + interval: config.interval, + }; + } else { + let config = price.config as UsagePriceConfig; + let priceOptions = getPriceOptions(price, cusProduct.options); + let usageTier = getUsageTier(price, priceOptions?.quantity!); + let cusEnt = getRelatedCusEnt({ + cusPrice: cp, + cusEnts: cusProduct.customer_entitlements, + }); + + let ent = cusEnt?.entitlement; + + let singleTier = ent?.allowance == 0 && config.usage_tiers.length == 1; + + if (singleTier) { + return { + amount: usageTier.amount, + interval: config.interval, + quantity: priceOptions?.quantity, + }; + } else { + // Add allowance to tiers + let allowance = ent?.allowance; + let tiers; + + if (notNullish(allowance) && allowance! > 0) { + tiers = [ + { + to: allowance, + amount: 0, + }, + ...config.usage_tiers.map((tier) => { + let isLastTier = tier.to == -1 || tier.to == TierInfinite; + return { + to: isLastTier ? tier.to : Number(tier.to) + allowance!, + amount: tier.amount, + }; + }), + ]; + } else { + tiers = config.usage_tiers.map((tier) => { + let isLastTier = tier.to == -1 || tier.to == TierInfinite; + return { + to: isLastTier ? tier.to : Number(tier.to) + allowance!, + amount: tier.amount, + }; + }); + } + + return { + tiers: tiers, + name: "", + quantity: priceOptions?.quantity, + }; + } + } + }); + + const trialing = + cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now(); + + const subIds = cusProduct.subscription_ids; + let stripeSubData = {}; + + if ( + subIds && + subIds.length > 0 && + org.config.api_version >= BREAK_API_VERSION + ) { + let baseSub = subs?.find( + (s) => s.id == subIds[0] || (s as Subscription).stripe_id == subIds[0], + ); + stripeSubData = { + current_period_end: baseSub?.current_period_end + ? baseSub.current_period_end * 1000 + : null, + current_period_start: baseSub?.current_period_start + ? baseSub.current_period_start * 1000 + : null, + }; + } + + if (!subIds && trialing) { + stripeSubData = { + current_period_start: cusProduct.starts_at, + current_period_end: cusProduct.trial_ends_at, + }; + } + + if (apiVersion >= APIVersion.v1_1) { + if ((!subIds || subIds.length == 0) && trialing) { + stripeSubData = { + current_period_start: cusProduct.starts_at, + current_period_end: cusProduct.trial_ends_at, + }; + } + + const fullProduct = fullCusProductToProduct(cusProduct); + const v2Product = await getProductResponse({ + product: fullProduct, + features, + withDisplay: false, + }); + + return CusProductResponseSchema.parse({ + id: fullProduct.id, + name: fullProduct.name, + group: fullProduct.group || null, + status: trialing ? CusProductStatus.Trialing : cusProduct.status, + canceled_at: cusProduct.canceled_at, + is_default: fullProduct.is_default || false, + is_add_on: fullProduct.is_add_on || false, + + stripe_subscription_ids: cusProduct.subscription_ids || [], + started_at: cusProduct.starts_at, + entity_id: cusProduct.internal_entity_id + ? entities?.find( + (e: Entity) => e.internal_id == cusProduct.internal_entity_id, + )?.id + : cusProduct.entity_id || undefined, + + ...stripeSubData, + items: v2Product.items, + }); + } else { + let cusProductResponse = { + id: cusProduct.product.id, + name: cusProduct.product.name, + group: cusProduct.product.group, + status: trialing ? CusProductStatus.Trialing : cusProduct.status, + created_at: cusProduct.created_at, + canceled_at: cusProduct.canceled_at, + processor: { + type: cusProduct.processor?.type, + subscription_id: cusProduct.processor?.subscription_id || null, + }, + subscription_ids: cusProduct.subscription_ids || [], + prices: prices, + starts_at: cusProduct.starts_at, + + ...stripeSubData, + }; + + return cusProductResponse; + } +}; diff --git a/server/src/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.ts b/server/src/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.ts new file mode 100644 index 000000000..1c698c6cb --- /dev/null +++ b/server/src/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.ts @@ -0,0 +1,49 @@ +import { + CusProductResponse, + Entity, + Feature, + Organization, +} from "@autumn/shared"; +import { getCusProductResponse } from "./getCusProductRepsonse.js"; + +export const processFullCusProducts = async ({ + fullCusProducts, + subs, + org, + entities = [], + apiVersion, + features, +}: { + fullCusProducts: any; + subs: any; + org: Organization; + entities?: Entity[]; + apiVersion: number; + features: Feature[]; +}) => { + // Process full cus products + let main = []; + let addOns = []; + for (const cusProduct of fullCusProducts) { + let processed = await getCusProductResponse({ + cusProduct, + subs, + org, + entities, + apiVersion, + features, + }); + + let isAddOn = cusProduct.product.is_add_on; + if (isAddOn) { + addOns.push(processed); + } else { + main.push(processed); + } + } + + return { + main: main as CusProductResponse[], + addOns: addOns as CusProductResponse[], + }; +}; diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils.ts b/server/src/internal/customers/cusUtils/cusResponseUtils.ts deleted file mode 100644 index 6e501fb9b..000000000 --- a/server/src/internal/customers/cusUtils/cusResponseUtils.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { processFullCusProducts } from "./cusUtils.js"; -import { - CusEntResponseSchema, - Entity, - Feature, - FeatureType, - FullCusProduct, - FullCustomerEntitlement, - Organization, - Subscription, -} from "@autumn/shared"; -import Stripe from "stripe"; - -export const getCusProductsResponse = async ({ - cusProducts, - entities, - subs, - org, - apiVersion, -}: { - cusProducts: FullCusProduct[]; - entities: Entity[]; - subs: (Stripe.Subscription | Subscription)[]; - org: Organization; - apiVersion: number; -}) => { - const { main, addOns } = processFullCusProducts({ - fullCusProducts: cusProducts, - subs, - org, - entities, - apiVersion, - }); - - let products: any = [...main, ...addOns]; - - return products; -}; diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusPaymentMethodRes.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusPaymentMethodRes.ts new file mode 100644 index 000000000..286a35290 --- /dev/null +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusPaymentMethodRes.ts @@ -0,0 +1,33 @@ +import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { AppEnv, CusExpand, FullCustomer, Organization } from "@autumn/shared"; + +export const getCusPaymentMethodRes = async ({ + org, + env, + fullCus, + expand, +}: { + org: Organization; + env: AppEnv; + fullCus: FullCustomer; + expand: CusExpand[]; +}) => { + if (!expand?.includes(CusExpand.PaymentMethod)) { + return undefined; + } + + let stripeCli = createStripeCli({ + org, + env, + }); + + let paymentMethod = await getCusPaymentMethod({ + stripeCli, + stripeId: fullCus.processor?.id, + errorIfNone: true, + }); + + return paymentMethod; +}; diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusReferrals.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusReferrals.ts new file mode 100644 index 000000000..c1bf640e0 --- /dev/null +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusReferrals.ts @@ -0,0 +1,39 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js"; +import { CusExpand, FullCustomer } from "@autumn/shared"; +import { CusReferralResponseSchema } from "@autumn/shared"; + +export const getCusReferrals = async ({ + db, + fullCus, + expand, +}: { + db: DrizzleCli; + fullCus: FullCustomer; + expand?: CusExpand[]; +}) => { + if (!expand?.includes(CusExpand.Referrals)) { + return undefined; + } + + let referred = await RewardRedemptionService.getByReferrer({ + db, + internalCustomerId: fullCus.internal_id, + withCustomer: true, + withRewardProgram: true, + limit: 100, + }); + + return referred.map((r) => + CusReferralResponseSchema.parse({ + program_id: r.reward_program?.id, + customer: { + id: r.customer.id, + name: r.customer.name, + email: r.customer.email, + }, + reward_applied: r.applied, + created_at: r.created_at, + }), + ); +}; diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusRewards.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusRewards.ts new file mode 100644 index 000000000..a928a89fc --- /dev/null +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusRewards.ts @@ -0,0 +1,100 @@ +import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { + AppEnv, + CouponDurationType, + CusExpand, + FullCustomer, + Organization, + RewardType, + Subscription, +} from "@autumn/shared"; +import Stripe from "stripe"; + +export const getCusRewards = async ({ + org, + env, + fullCus, + subs, + subIds, + expand, +}: { + org: Organization; + env: AppEnv; + fullCus: FullCustomer; + subs?: Stripe.Subscription[]; + subIds?: string[]; + expand?: CusExpand[]; +}) => { + if (!expand?.includes(CusExpand.Rewards)) { + return undefined; + } + + if (!fullCus.processor?.id) { + return undefined; + } + + let stripeCli = createStripeCli({ + org, + env, + }); + + const [stripeCus, subsResult] = await Promise.all([ + stripeCli.customers.retrieve( + fullCus.processor?.id!, + ) as Promise, + !subs + ? getStripeSubs({ + stripeCli, + subIds, + expand: ["discounts"], + }) + : null, + ]); + + if (!subs && subsResult) { + subs = subsResult; + } + + let stripeDiscounts: Stripe.Discount[] = subs?.flatMap( + (s) => s.discounts, + ) as Stripe.Discount[]; + + if (stripeCus.discount) { + stripeDiscounts.push(stripeCus.discount); + } + + let rewards = { + discounts: stripeDiscounts.map((d) => { + let duration_type: CouponDurationType; + let duration_value = 0; + if (d.coupon?.duration === "forever") { + duration_type = CouponDurationType.Forever; + } else if (d.coupon?.duration === "once") { + duration_type = CouponDurationType.OneOff; + } else if (d.coupon?.duration === "repeating") { + duration_type = CouponDurationType.Months; + duration_value = d.coupon?.duration_in_months || 0; + } else { + duration_type = CouponDurationType.OneOff; + } + 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, + }; + }), + }; + + return rewards; +}; diff --git a/server/src/internal/customers/cusUtils/cusUtils.ts b/server/src/internal/customers/cusUtils/cusUtils.ts index d76d06e24..6b8a42f6b 100644 --- a/server/src/internal/customers/cusUtils/cusUtils.ts +++ b/server/src/internal/customers/cusUtils/cusUtils.ts @@ -107,42 +107,6 @@ export const getCusInvoices = async ({ return processedInvoices; }; -export const processFullCusProducts = ({ - fullCusProducts, - subs, - org, - entities = [], - apiVersion, -}: { - fullCusProducts: any; - subs: any; - org: Organization; - entities?: Entity[]; - apiVersion: number; -}) => { - // Process full cus products - let main = []; - let addOns = []; - for (const cusProduct of fullCusProducts) { - let processed = processFullCusProduct({ - cusProduct, - subs, - org, - entities, - apiVersion, - }); - - let isAddOn = cusProduct.product.is_add_on; - if (isAddOn) { - addOns.push(processed); - } else { - main.push(processed); - } - } - - return { main, addOns }; -}; - // IMPORTANT FUNCTION export const getCusEntsInFeatures = async ({ customer, diff --git a/server/src/internal/customers/cusUtils/getCustomerDetails.ts b/server/src/internal/customers/cusUtils/getCustomerDetails.ts index e8cc95462..f526d09f2 100644 --- a/server/src/internal/customers/cusUtils/getCustomerDetails.ts +++ b/server/src/internal/customers/cusUtils/getCustomerDetails.ts @@ -1,8 +1,6 @@ import Stripe from "stripe"; import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { createStripeCli } from "@/external/stripe/utils.js"; -import { getCusBalances } from "@/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusBalances.js"; - import { BREAK_API_VERSION } from "@/utils/constants.js"; import { AppEnv, @@ -15,8 +13,6 @@ import { FeatureType, Feature, Organization, - CusEntResponse, - CusEntResponseV2, FullCustomer, CusExpand, RewardType, @@ -24,7 +20,7 @@ import { CouponDurationType, EntityResponseSchema, } from "@autumn/shared"; -import { getCusInvoices, processFullCusProducts } from "./cusUtils.js"; +import { getCusInvoices } from "./cusUtils.js"; import { orgToVersion } from "@/utils/versionUtils.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; @@ -33,7 +29,12 @@ import { cusProductsToCusPrices, } from "../cusProducts/cusProductUtils/convertCusProduct.js"; import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js"; -import { featuresToObject } from "../cusProducts/cusEnts/cusFeatureUtils/balancesToFeatureResponse.js"; +import { getCusBalances } from "./cusFeatureResponseUtils/getCusBalances.js"; +import { featuresToObject } from "./cusFeatureResponseUtils/balancesToFeatureResponse.js"; +import { processFullCusProducts } from "./cusProductResponseUtils/processFullCusProducts.js"; +import { getCusReferrals } from "./cusResponseUtils/getCusReferrals.js"; +import { getCusRewards } from "./cusResponseUtils/getCusRewards.js"; +import { getCusPaymentMethodRes } from "./cusResponseUtils/getCusPaymentMethodRes.js"; export const getCustomerDetails = async ({ db, @@ -95,12 +96,13 @@ export const getCustomerDetails = async ({ }); } - const { main, addOns } = processFullCusProducts({ + const { main, addOns } = await processFullCusProducts({ fullCusProducts: cusProducts, subs, org, apiVersion, entities: customer.entities, + features, }); if (apiVersion >= APIVersion.v1_1) { @@ -130,69 +132,27 @@ export const getCustomerDetails = async ({ let withInvoices = expand.includes(CusExpand.Invoices); - let rewards: RewardResponse | undefined; - if (withRewards && customer.processor?.id) { - let stripeCli = createStripeCli({ - org, - env, - }); + let rewards: RewardResponse | undefined = await getCusRewards({ + org, + env, + fullCus: customer, + subs, + subIds, + expand, + }); - const [stripeCus, subsResult] = await Promise.all([ - stripeCli.customers.retrieve( - customer.processor?.id!, - ) as Promise, - !subs - ? getStripeSubs({ - stripeCli, - subIds, - expand: ["discounts"], - }) - : null, - ]); + let referrals = await getCusReferrals({ + db, + fullCus: customer, + expand, + }); - if (!subs && subsResult) { - subs = subsResult; - } - - let stripeDiscounts: Stripe.Discount[] = subs?.flatMap( - (s) => s.discounts, - ) as Stripe.Discount[]; - - if (stripeCus.discount) { - stripeDiscounts.push(stripeCus.discount); - } - - rewards = { - discounts: stripeDiscounts.map((d) => { - let duration_type: CouponDurationType; - let duration_value = 0; - if (d.coupon?.duration === "forever") { - duration_type = CouponDurationType.Forever; - } else if (d.coupon?.duration === "once") { - duration_type = CouponDurationType.OneOff; - } else if (d.coupon?.duration === "repeating") { - duration_type = CouponDurationType.Months; - duration_value = d.coupon?.duration_in_months || 0; - } else { - duration_type = CouponDurationType.OneOff; - } - 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, - }; - }), - }; - } + let paymentMethod = await getCusPaymentMethodRes({ + org, + env, + fullCus: customer, + expand, + }); let cusResponse = { ...CusResponseSchema.parse({ @@ -224,6 +184,8 @@ export const getCustomerDetails = async ({ }), ) : undefined, + referrals, + payment_method: paymentMethod, }), }; diff --git a/server/src/internal/customers/handlers/handleGetCustomer.ts b/server/src/internal/customers/handlers/handleGetCustomer.ts index 7248db239..64c2e5005 100644 --- a/server/src/internal/customers/handlers/handleGetCustomer.ts +++ b/server/src/internal/customers/handlers/handleGetCustomer.ts @@ -1,11 +1,9 @@ import { CusService } from "@/internal/customers/CusService.js"; import { routeHandler } from "@/utils/routerUtils.js"; -import { CusExpand, CusProductStatus, ErrCode } from "@autumn/shared"; +import { CusProductStatus, ErrCode } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import { getCustomerDetails } from "../cusUtils/getCustomerDetails.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; import { parseCusExpand } from "../cusUtils/cusUtils.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; export const handleGetCustomer = async (req: any, res: any) => routeHandler({ @@ -32,7 +30,6 @@ export const handleGetCustomer = async (req: any, res: any) => CusProductStatus.Scheduled, ], withEntities: true, - // withEntities: expandArray.includes(CusExpand.Entities), expand: expandArray, allowNotFound: true, }); diff --git a/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity.ts b/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity.ts index a883fed9e..35430c0aa 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity.ts @@ -93,6 +93,7 @@ export const createEntities = async ({ customerId: customer.id || customer.internal_id, withAutumnId, apiVersion: apiVersion!, + features, }); return entities; diff --git a/server/src/internal/products/product-items/productItemUtils.ts b/server/src/internal/products/product-items/productItemUtils.ts index 65ec4c5e4..00e83f41e 100644 --- a/server/src/internal/products/product-items/productItemUtils.ts +++ b/server/src/internal/products/product-items/productItemUtils.ts @@ -41,10 +41,12 @@ export const itemToPriceOrTiers = ({ amount: item.price, }) : item.price, + tiers: undefined, }; } else if (item.tiers) { if (item.tiers.length > 1) { return { + price: undefined, tiers: item.tiers.map((tier) => ({ ...tier, amount: proration @@ -67,6 +69,7 @@ export const itemToPriceOrTiers = ({ amount: item.tiers[0].amount, }) : item.tiers[0].amount, + tiers: undefined, }; } } diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts index 6d71c2319..f398dd770 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts @@ -24,15 +24,18 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { notNullish } from "@/utils/genUtils.js"; import { isFreeProduct, isOneOff } from "../../productUtils.js"; import { getFirstInterval } from "../../prices/priceUtils/priceIntervalUtils.js"; +import { itemToPriceOrTiers } from "../../product-items/productItemUtils.js"; export const getProductItemResponse = ({ item, features, currency, + withDisplay = true, }: { item: ProductItem; features: Feature[]; currency?: string; + withDisplay?: boolean; }) => { // 1. Get item type let type = getItemType(item); @@ -44,10 +47,13 @@ export const getProductItemResponse = ({ currency, }); + let priceData = itemToPriceOrTiers({ item }); + return ProductItemResponseSchema.parse({ type, ...item, - display, + display: withDisplay ? display : undefined, + ...priceData, }); }; @@ -113,12 +119,14 @@ export const getProductResponse = async ({ fullCus, currency, db, + withDisplay = true, }: { product: FullProduct; features: Feature[]; fullCus?: FullCustomer; currency?: string; db?: DrizzleCli; + withDisplay?: boolean; }) => { // 1. Get items with display let items = mapToProductItems({ @@ -130,6 +138,7 @@ export const getProductResponse = async ({ item, features, currency, + withDisplay, }); }); @@ -153,7 +162,7 @@ export const getProductResponse = async ({ name: product.name || null, group: product.group || null, items: items, - free_trial: freeTrial, + free_trial: freeTrial || null, scenario: attachScenario, properties: getProductProperties({ product }), }); diff --git a/server/src/internal/rewards/RewardRedemptionService.ts b/server/src/internal/rewards/RewardRedemptionService.ts index 64f9d6db1..e3fcfc63e 100644 --- a/server/src/internal/rewards/RewardRedemptionService.ts +++ b/server/src/internal/rewards/RewardRedemptionService.ts @@ -79,13 +79,15 @@ export class RewardRedemptionService { internalCustomerId, withCustomer = false, limit = 100, + withRewardProgram = false, }: { db: DrizzleCli; internalCustomerId: string; withCustomer?: boolean; limit?: number; + withRewardProgram?: boolean; }) { - const data = await db + let query = db .select() .from(rewardRedemptions) .innerJoin( @@ -95,8 +97,18 @@ export class RewardRedemptionService { .innerJoin( customers, eq(rewardRedemptions.internal_customer_id, customers.internal_id), - ) + ); + if (withRewardProgram) { + query = query.innerJoin( + rewardPrograms, + eq( + rewardRedemptions.internal_reward_program_id, + rewardPrograms.internal_id, + ), + ); + } + const data = await query .where(eq(referralCodes.internal_customer_id, internalCustomerId)) .limit(limit); @@ -104,6 +116,9 @@ export class RewardRedemptionService { ...d.reward_redemptions, referral_code: d.referral_codes, customer: d.customers, + reward_program: withRewardProgram + ? (d as any).reward_programs + : undefined, })); return processed; diff --git a/server/src/internal/rewards/referralUtils.ts b/server/src/internal/rewards/referralUtils.ts index c84726a6c..68cb239aa 100644 --- a/server/src/internal/rewards/referralUtils.ts +++ b/server/src/internal/rewards/referralUtils.ts @@ -146,6 +146,7 @@ export const triggerFreeProduct = async ({ received_by === RewardReceivedBy.All; let productId = rewardProgram.reward.free_product_id!; + let fullProduct = await ProductService.getFull({ db, idOrInternalId: productId, diff --git a/server/tests/utils/expectUtils/expectProductAttached.ts b/server/tests/utils/expectUtils/expectProductAttached.ts index 3b2c8d3ce..8080ae00e 100644 --- a/server/tests/utils/expectUtils/expectProductAttached.ts +++ b/server/tests/utils/expectUtils/expectProductAttached.ts @@ -93,10 +93,11 @@ export const expectInvoicesCorrect = ({ // invoices![1].product_ids, // ); try { - expect( - totalAmount == second.total, + expect(totalAmount).to.approximately( + second.total, + 0.01, `first & second invoice total should sum to ${second.total}`, - ).to.be.true; + ); expect( invoices![0].product_ids.includes(second.productId), `invoice 1 includes product ${second.productId}`, diff --git a/shared/index.ts b/shared/index.ts index 1111a60f6..7bd98f261 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -79,6 +79,7 @@ export * from "./models/cusModels/invoiceModels/invoiceTable.js"; export * from "./models/cusModels/cusResponseModels.js"; export * from "./models/cusModels/cusResModels/cusProductResponse.js"; export * from "./models/cusModels/cusResModels/cusFeatureResponse.js"; +export * from "./models/cusModels/cusResModels/cusReferralsResponse.js"; export * from "./models/cusModels/entityModels/entityModels.js"; export * from "./models/cusModels/entityModels/entityTable.js"; diff --git a/shared/models/cusModels/cusExpand.ts b/shared/models/cusModels/cusExpand.ts index 659d0caa2..a53c144de 100644 --- a/shared/models/cusModels/cusExpand.ts +++ b/shared/models/cusModels/cusExpand.ts @@ -3,4 +3,6 @@ export enum CusExpand { TrialsUsed = "trials_used", Rewards = "rewards", Entities = "entities", + Referrals = "referrals", + PaymentMethod = "payment_method", } diff --git a/shared/models/cusModels/cusResModels/cusProductResponse.ts b/shared/models/cusModels/cusResModels/cusProductResponse.ts index 517c856e8..67b4d9c21 100644 --- a/shared/models/cusModels/cusResModels/cusProductResponse.ts +++ b/shared/models/cusModels/cusResModels/cusProductResponse.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { CusProductStatus } from "../../cusProductModels/cusProductEnums.js"; +import { ProductItemResponseSchema } from "../../productV2Models/productItemModels/prodItemResponseModels.js"; export const CusProductResponseSchema = z.object({ id: z.string(), @@ -17,4 +18,5 @@ export const CusProductResponseSchema = z.object({ current_period_start: z.number().nullish(), current_period_end: z.number().nullish(), entity_id: z.string().nullish(), + items: z.array(ProductItemResponseSchema).nullish(), }); diff --git a/shared/models/cusModels/cusResModels/cusReferralsResponse.ts b/shared/models/cusModels/cusResModels/cusReferralsResponse.ts new file mode 100644 index 000000000..ad497e141 --- /dev/null +++ b/shared/models/cusModels/cusResModels/cusReferralsResponse.ts @@ -0,0 +1,14 @@ +import z from "zod"; + +export const CusReferralResponseSchema = z.object({ + program_id: z.string(), + customer: z.object({ + id: z.string(), + name: z.string().nullish(), + email: z.string().nullish(), + }), + reward_applied: z.boolean(), + created_at: z.number(), +}); + +export type CusReferralResponse = z.infer; diff --git a/shared/models/cusModels/cusResponseModels.ts b/shared/models/cusModels/cusResponseModels.ts index dd1dee3d5..2ce753eb1 100644 --- a/shared/models/cusModels/cusResponseModels.ts +++ b/shared/models/cusModels/cusResponseModels.ts @@ -4,6 +4,7 @@ import { InvoiceResponseSchema } from "./invoiceModels/invoiceResponseModels.js" import { RewardResponseSchema } from "../rewardModels/rewardModels/rewardResponseModels.js"; import { EntityResponseSchema } from "./entityModels/entityResModels.js"; import { CusProductResponseSchema } from "./cusResModels/cusProductResponse.js"; +import { CusReferralResponseSchema } from "./cusResModels/cusReferralsResponse.js"; export const TrialUsedResponseSchema = z.object({ product_id: z.string(), @@ -31,6 +32,8 @@ export const CusResponseSchema = z.object({ rewards: RewardResponseSchema.nullish(), metadata: z.record(z.any()).default({}), entities: z.array(EntityResponseSchema).optional(), + referrals: z.array(CusReferralResponseSchema).optional(), + payment_method: z.any().nullish(), }); export type CusResponse = z.infer;