@coursebuilder/core
Version:
Core package for Course Builder
236 lines (199 loc) • 6.49 kB
text/typescript
import { sum } from '@coursebuilder/nodash'
import { CourseBuilderAdapter } from '../../adapters'
import { Price, Product, Purchase } from '../../schemas'
import { FormatPricesForProductOptions, FormattedPrice } from '../../types'
import { determineCouponToApply } from './determine-coupon-to-apply.js'
import { getCalculatedPrice } from './get-calculated-price.js'
// 10% premium for an upgrade
// TODO: Display Coupon Errors
// TODO: Display Applied Site Coupon w/ Expiration
// departure from the three tiers we've used in the past and the third tier
// is for teams
export class PriceFormattingError extends Error {
options: Partial<FormatPricesForProductOptions>
constructor(
message: string,
options: Partial<FormatPricesForProductOptions>,
) {
super(message)
this.name = 'PriceFormattingError'
this.options = options
}
}
async function getChainOfPurchases({
purchase,
ctx,
}: {
purchase: Purchase | null
ctx: CourseBuilderAdapter
}): Promise<Purchase[]> {
if (purchase === null) {
return []
} else {
const { getPurchase } = ctx
const { upgradedFromId } = purchase
const purchaseThisWasUpgradedFrom = upgradedFromId
? await getPurchase(upgradedFromId)
: null
return [
purchase,
...(await getChainOfPurchases({
purchase: purchaseThisWasUpgradedFrom,
ctx,
})),
]
}
}
export async function getFixedDiscountForIndividualUpgrade({
purchaseToBeUpgraded,
productToBePurchased,
purchaseWillBeRestricted,
userId,
ctx,
}: {
purchaseToBeUpgraded: Purchase | null
productToBePurchased: Product
purchaseWillBeRestricted: boolean
userId: string | undefined
ctx: CourseBuilderAdapter
}) {
// if there is no purchase to be upgraded, then this isn't an upgrade
// and the Fixed Discount should be 0.
if (
purchaseToBeUpgraded === null ||
purchaseToBeUpgraded?.productId === undefined
) {
return 0
}
const transitioningToUnrestrictedAccess =
purchaseToBeUpgraded.status === 'Restricted' && !purchaseWillBeRestricted
// if the Purchase To Be Upgraded is `restricted` and it has a matching
// `productId` with the Product To Be Purchased, then this is a PPP
// upgrade, so use the purchase amount.
if (transitioningToUnrestrictedAccess) {
const purchaseChain = await getChainOfPurchases({
purchase: purchaseToBeUpgraded,
ctx,
})
return sum(purchaseChain.map((purchase) => purchase.totalAmount))
}
// if Purchase To Be Upgraded is upgradeable to the Product To Be Purchased,
// then look up the Price of the original product
const { availableUpgradesForProduct, pricesOfPurchasesTowardOneBundle } = ctx
const availableUpgrades = await availableUpgradesForProduct(
[purchaseToBeUpgraded],
productToBePurchased.id,
)
const upgradeIsAvailable = availableUpgrades.length > 0
if (upgradeIsAvailable) {
const pricesToBeDiscounted = await pricesOfPurchasesTowardOneBundle({
userId,
bundleId: productToBePurchased.id,
})
const pricesArray = pricesToBeDiscounted.map((price: Price) => {
return price.unitAmount
})
return sum(pricesArray)
}
return 0
}
/**
* Creates a verified price for a given product based on the unit price
* of the product, coupons, and other factors.
*
* 30 minute loom walkthrough of this function:
* https://www.loom.com/share/8cbd2213d44145dea51590b380f5d0d7?sid=bec3caeb-b742-4425-ae6e-81ca98c88f91
*
* @param {FormatPricesForProductOptions} options the Prisma context
*/
export async function formatPricesForProduct(
options: FormatPricesForProductOptions,
): Promise<FormattedPrice> {
const { ctx, ...noContextOptions } = options
const {
productId,
country = 'US',
quantity = 1,
merchantCouponId,
upgradeFromPurchaseId,
userId,
autoApplyPPP = true,
usedCouponId,
} = noContextOptions
if (!productId) throw new Error('productId is required')
const { getProduct, getPriceForProduct, getPurchase, getCoupon } = ctx
const usedCoupon = usedCouponId ? await getCoupon(usedCouponId) : null
// TODO subscription versus single purchase
const upgradeFromPurchase = upgradeFromPurchaseId
? await getPurchase(upgradeFromPurchaseId)
: null
const upgradedProduct = upgradeFromPurchase
? await getProduct(upgradeFromPurchase.productId)
: null
const product = await getProduct(productId)
if (!product) {
throw new PriceFormattingError(`no-product-found`, noContextOptions)
}
const price = await getPriceForProduct(productId)
if (!price) throw new PriceFormattingError(`no-price-found`, noContextOptions)
// TODO: give this function a better name like, `determineCouponDetails`
const { appliedMerchantCoupon, appliedCouponType, ...result } =
await determineCouponToApply({
prismaCtx: ctx,
merchantCouponId,
country,
quantity,
userId,
productId: product.id,
purchaseToBeUpgraded: upgradeFromPurchase,
autoApplyPPP,
usedCoupon,
})
const fireFixedDiscountForIndividualUpgrade = async () => {
return await getFixedDiscountForIndividualUpgrade({
purchaseToBeUpgraded: upgradeFromPurchase,
productToBePurchased: product,
purchaseWillBeRestricted: appliedCouponType === 'ppp',
userId,
ctx,
})
}
// Right now, we have fixed discounts to apply to upgrades for individual
// purchases. If it is a bulk purchase, a fixed discount shouldn't be
// applied. It's likely this will change in the future, so this allows us
// to handle both and distinguishes them as two different flows.
const fixedDiscountForUpgrade = result.bulk
? 0
: await fireFixedDiscountForIndividualUpgrade()
const unitPrice: number = price.unitAmount
const fullPrice: number = unitPrice * quantity - fixedDiscountForUpgrade
const percentOfDiscount = appliedMerchantCoupon?.percentageDiscount
const upgradeDetails =
upgradeFromPurchase !== null && appliedCouponType !== 'bulk' // we don't handle bulk with upgrades (yet), so be explicit here
? {
upgradeFromPurchaseId,
upgradeFromPurchase,
upgradedProduct,
}
: {}
return {
...product,
quantity,
unitPrice,
fullPrice,
fixedDiscountForUpgrade,
calculatedPrice: getCalculatedPrice({
unitPrice,
percentOfDiscount,
fixedDiscount: fixedDiscountForUpgrade, // if not upgrade, we know this will be 0
quantity, // if PPP is applied, we know this will be 1
}),
availableCoupons: result.availableCoupons,
appliedMerchantCoupon,
...(usedCoupon?.merchantCouponId === appliedMerchantCoupon?.id && {
usedCouponId,
}),
bulk: result.bulk,
...upgradeDetails,
}
}