UNPKG

@coursebuilder/core

Version:

Core package for Course Builder

387 lines (336 loc) 11 kB
import { z } from 'zod' import { CourseBuilderAdapter, MockCourseBuilderAdapter } from '../../adapters' import { MerchantCoupon, Purchase } from '../../schemas' import { MinimalMerchantCoupon } from '../../types' import { getBulkDiscountPercent } from './bulk-coupon.js' import { getPPPDiscountPercent } from './parity-coupon.js' const PrismaCtxSchema: z.ZodType<CourseBuilderAdapter> = z.any() const PurchaseSchema: z.ZodType<Purchase> = z.any() const DetermineCouponToApplyParamsSchema = z.object({ prismaCtx: PrismaCtxSchema, merchantCouponId: z.string().optional(), country: z.string(), quantity: z.number(), userId: z.string().optional(), productId: z.string(), purchaseToBeUpgraded: PurchaseSchema.nullable(), autoApplyPPP: z.boolean(), usedCoupon: z .object({ merchantCouponId: z.string().nullable().optional(), restrictedToProductId: z.string().nullable().optional(), }) .nullable() .optional(), }) type DetermineCouponToApplyParams = z.infer< typeof DetermineCouponToApplyParamsSchema > const SPECIAL_TYPE = 'special' as const const PPP_TYPE = 'ppp' as const const BULK_TYPE = 'bulk' as const const NONE_TYPE = 'none' as const export const determineCouponToApply = async ( params: DetermineCouponToApplyParams, ) => { const { prismaCtx, merchantCouponId, country, quantity, userId, productId, purchaseToBeUpgraded, autoApplyPPP, usedCoupon, } = DetermineCouponToApplyParamsSchema.parse(params) // TODO: What are the lookups and logic checks we can // skip when there is no appliedMerchantCouponId? const { getMerchantCoupon, getPurchasesForUser } = prismaCtx // if usedCoupon is restricted to a different product, we shouldn't apply it const couponRestrictedToDifferentProduct = usedCoupon?.merchantCouponId === merchantCouponId && usedCoupon?.restrictedToProductId && usedCoupon?.restrictedToProductId !== productId const candidateMerchantCoupon = !couponRestrictedToDifferentProduct && merchantCouponId ? await getMerchantCoupon(merchantCouponId) : null const specialMerchantCouponToApply = candidateMerchantCoupon?.type === SPECIAL_TYPE ? candidateMerchantCoupon : null const userPurchases = await getPurchasesForUser(userId) const pppDetails = await getPPPDetails({ specialMerchantCoupon: specialMerchantCouponToApply, appliedMerchantCoupon: candidateMerchantCoupon, country, quantity, purchaseToBeUpgraded, userPurchases, autoApplyPPP, prismaCtx, }) const { bulkCouponToBeApplied, consideredBulk } = await getBulkCouponDetails({ prismaCtx, userId, productId, quantity, appliedMerchantCoupon: specialMerchantCouponToApply, pppApplied: pppDetails.pppApplied, }) let couponToApply: MinimalMerchantCoupon | null = null if (pppDetails.status === VALID_PPP) { couponToApply = pppDetails.pppCouponToBeApplied } else if (bulkCouponToBeApplied) { couponToApply = bulkCouponToBeApplied } else { couponToApply = specialMerchantCouponToApply } // It is only every PPP that ends up in the Available Coupons // list because with Special and Bulk we auto-apply those if // they are the best discount. const availableCoupons = pppDetails.availableCoupons // Narrow appliedCouponType to a union of consts const appliedCouponType = z .string() .nullish() .transform((couponType) => { if (couponType === PPP_TYPE) { return PPP_TYPE } else if (couponType === SPECIAL_TYPE) { return SPECIAL_TYPE } else if (couponType === BULK_TYPE) { return BULK_TYPE } else { return NONE_TYPE } }) .parse(couponToApply?.type) return { appliedMerchantCoupon: couponToApply || undefined, appliedCouponType, availableCoupons, bulk: consideredBulk, } } type UserPurchases = Awaited< ReturnType<CourseBuilderAdapter['getPurchasesForUser']> > const UserPurchasesSchema: z.ZodType<UserPurchases> = z.any() const MerchantCouponSchema: z.ZodType<MerchantCoupon> = z.any() const GetPPPDetailsParamsSchema = z.object({ specialMerchantCoupon: MerchantCouponSchema.nullable(), appliedMerchantCoupon: MerchantCouponSchema.nullable(), quantity: z.number(), country: z.string(), purchaseToBeUpgraded: PurchaseSchema.nullable(), userPurchases: UserPurchasesSchema, autoApplyPPP: z.boolean(), prismaCtx: PrismaCtxSchema, }) type GetPPPDetailsParams = z.infer<typeof GetPPPDetailsParamsSchema> const NO_PPP = 'NO_PPP' as const const INVALID_PPP = 'INVALID_PPP' as const const VALID_PPP = 'VALID_PPP' as const const getPPPDetails = async ({ specialMerchantCoupon, appliedMerchantCoupon, country, quantity, purchaseToBeUpgraded, userPurchases, autoApplyPPP, prismaCtx, }: GetPPPDetailsParams) => { const hasMadeNonPPPDiscountedPurchase = userPurchases.some( (purchase) => purchase.status === 'Valid', ) const hasOnlyPPPDiscountedPurchases = !hasMadeNonPPPDiscountedPurchase const expectedPPPDiscountPercent = getPPPDiscountPercent(country) const shouldLookupPPPMerchantCouponForUpgrade = appliedMerchantCoupon === null && purchaseToBeUpgraded !== null && hasOnlyPPPDiscountedPurchases && autoApplyPPP let pppMerchantCouponForUpgrade: MerchantCoupon | null = null if (shouldLookupPPPMerchantCouponForUpgrade) { pppMerchantCouponForUpgrade = await lookupApplicablePPPMerchantCoupon({ prismaCtx, pppDiscountPercent: expectedPPPDiscountPercent, }) } const pppCouponToBeApplied = appliedMerchantCoupon?.type === PPP_TYPE ? appliedMerchantCoupon : pppMerchantCouponForUpgrade // TODO: Move this sort of price comparison to the parent method, for the // purposes of this method we'll just assume that if the PPP looks // good and can be applied, then it is a candidate. const pppDiscountIsBetter = (specialMerchantCoupon?.percentageDiscount || 0) < expectedPPPDiscountPercent const pppConditionsMet = expectedPPPDiscountPercent > 0 && quantity === 1 && hasOnlyPPPDiscountedPurchases && pppDiscountIsBetter // Build `details` with all kinds of intermediate stuff as part of this refactoring const pppApplied = quantity === 1 && appliedMerchantCoupon?.type === 'ppp' && expectedPPPDiscountPercent > 0 // NOTE: PPP coupons are only *available* if the conditions are met // which includes that the PPP discount will be better than any // site-wide default coupon. let availableCoupons: Awaited<ReturnType<typeof couponForType>> = [] if (pppConditionsMet) { availableCoupons = await couponForType( PPP_TYPE, expectedPPPDiscountPercent, prismaCtx, country, ) } const baseDetails = { pppApplied: false, pppCouponToBeApplied: null, availableCoupons, } if (pppCouponToBeApplied === null) { return { ...baseDetails, status: NO_PPP, } } // Check *applied* PPP coupon validity const couponPercentDoesNotMatchCountry = expectedPPPDiscountPercent !== pppCouponToBeApplied?.percentageDiscount const couponPercentOutOfRange = expectedPPPDiscountPercent <= 0 || expectedPPPDiscountPercent >= 1 const pppAppliedToBulkPurchase = quantity > 1 const invalidCoupon = couponPercentDoesNotMatchCountry || couponPercentOutOfRange || pppAppliedToBulkPurchase if (invalidCoupon) { return { ...baseDetails, status: INVALID_PPP, availableCoupons: [], } } return { ...baseDetails, status: VALID_PPP, pppApplied, pppCouponToBeApplied, } } const LookupApplicablePPPMerchantCouponParamsSchema = z.object({ prismaCtx: PrismaCtxSchema, pppDiscountPercent: z.number(), }) type LookupApplicablePPPMerchantCouponParams = z.infer< typeof LookupApplicablePPPMerchantCouponParamsSchema > // TODO: Should we cross-check the incoming `pppDiscountPercent` with // the `discountPercentage` that was applied to the original purchase? const lookupApplicablePPPMerchantCoupon = async ( params: LookupApplicablePPPMerchantCouponParams, ) => { const { prismaCtx, pppDiscountPercent } = LookupApplicablePPPMerchantCouponParamsSchema.parse(params) const { getMerchantCouponForTypeAndPercent } = prismaCtx const pppMerchantCoupon = await getMerchantCouponForTypeAndPercent({ type: PPP_TYPE, percentageDiscount: pppDiscountPercent, }) // early return if there is no PPP coupon that fits the bill // report this to Sentry? Seems like a bug if we aren't able to find one. if (pppMerchantCoupon === null) return null return pppMerchantCoupon } const GetBulkCouponDetailsParamsSchema = z.object({ prismaCtx: PrismaCtxSchema, userId: z.string().optional(), productId: z.string(), quantity: z.number(), appliedMerchantCoupon: MerchantCouponSchema.nullable(), pppApplied: z.boolean(), }) type GetBulkCouponDetailsParams = z.infer< typeof GetBulkCouponDetailsParamsSchema > const getBulkCouponDetails = async (params: GetBulkCouponDetailsParams) => { const { prismaCtx, userId, productId, quantity, appliedMerchantCoupon, pppApplied, } = GetBulkCouponDetailsParamsSchema.parse(params) // Determine if the user has an existing bulk purchase of this product. // If so, we can compute tiered pricing based on their existing seats purchased. const seatCount = await getQualifyingSeatCount({ userId, productId, newPurchaseQuantity: quantity, prismaCtx, }) const consideredBulk = seatCount > 1 const bulkCouponPercent = getBulkDiscountPercent(seatCount) const bulkDiscountIsBetter = (appliedMerchantCoupon?.percentageDiscount || 0) < bulkCouponPercent const bulkDiscountAvailable = bulkCouponPercent > 0 && bulkDiscountIsBetter && !pppApplied // this condition seems irrelevant, if quantity > 1 OR seatCount > 1 if (bulkDiscountAvailable) { const bulkCoupons = await couponForType( BULK_TYPE, bulkCouponPercent, prismaCtx, ) const bulkCoupon = bulkCoupons[0] return { bulkCouponToBeApplied: bulkCoupon, consideredBulk } } else { return { bulkCouponToBeApplied: null, consideredBulk } } } const getQualifyingSeatCount = async ({ userId, productId: purchasingProductId, newPurchaseQuantity, prismaCtx, }: { userId: string | undefined productId: string newPurchaseQuantity: number prismaCtx: CourseBuilderAdapter }) => { const { getPurchasesForUser } = prismaCtx const userPurchases = await getPurchasesForUser(userId) const bulkPurchase = userPurchases.find( ({ productId, bulkCoupon }) => productId === purchasingProductId && Boolean(bulkCoupon), ) const existingSeatsPurchasedForThisProduct = bulkPurchase?.bulkCoupon?.maxUses || 0 return newPurchaseQuantity + existingSeatsPurchasedForThisProduct } async function couponForType( type: string, percentageDiscount: number, prismaCtx: CourseBuilderAdapter, country?: string, ) { const { getMerchantCouponsForTypeAndPercent } = prismaCtx const merchantCoupons = (await getMerchantCouponsForTypeAndPercent({ type, percentageDiscount })) || [] return merchantCoupons.map((coupon: MerchantCoupon) => { // for pricing we don't need the identifier so strip it here const { identifier, ...rest } = coupon return { ...rest, ...(country && { country }) } }) }