UNPKG

@coursebuilder/core

Version:

Core package for Course Builder

502 lines (435 loc) 13.8 kB
import { add } from 'date-fns' import { CourseBuilderAdapter } from 'src/adapters' import { CheckoutSessionMetadataSchema } from 'src/schemas/stripe/checkout-session-metadata' import Stripe from 'stripe' import { z } from 'zod' import { first, isEmpty } from '@coursebuilder/nodash' import { Product, Purchase, UpgradableProduct } from '../../schemas' import { PaymentsAdapter, PaymentsProviderConsumerConfig } from '../../types' import { getFixedDiscountForIndividualUpgrade } from './format-prices-for-product' import { getCalculatedPrice } from './get-calculated-price' export const CheckoutParamsSchema = z.object({ ip_address: z.string().optional(), productId: z.string(), quantity: z.coerce .number() .optional() .transform((val) => Number(val) || 0), country: z.string().optional(), couponId: z.string().optional(), userId: z.string().optional(), upgradeFromPurchaseId: z.string().optional(), bulk: z.preprocess((val) => { return val === 'false' ? false : Boolean(val) }, z.coerce.boolean()), cancelUrl: z.string(), usedCouponId: z.string().optional(), organizationId: z.string().optional(), }) export type CheckoutParams = z.infer<typeof CheckoutParamsSchema> const buildSearchParams = (params: object) => { // implementing this instead of using `URLSearchParams` because that API // does URL encoding of values in the URL like the curly braces in // `session_id={CHECKOUT_SESSION_ID}` which needs to get passed to stripe // as is. if (isEmpty(params)) { return '' } else { return Object.entries(params) .map(([key, value]) => { return `${key}=${value}` }) .join('&') } } /** * Given a specific user we want to lookup their Stripe * customer ID and if one doesn't exist we will * create it. * @param userId * @param adapter * @param paymentsAdapter */ async function findOrCreateStripeCustomerId( userId: string, adapter: CourseBuilderAdapter, paymentsAdapter: PaymentsAdapter, ) { const user = await adapter.getUser?.(userId) if (user) { const merchantCustomer = await adapter.getMerchantCustomerForUserId(user.id) const customerId = user && merchantCustomer ? merchantCustomer.identifier : false if (customerId) { return customerId } else { const merchantAccount = await adapter.getMerchantAccount({ provider: 'stripe', }) if (merchantAccount) { const customerId = await paymentsAdapter.createCustomer({ email: user.email, metadata: { userId: user.id, }, }) await adapter.createMerchantCustomer({ identifier: customerId, merchantAccountId: merchantAccount.id, userId, }) return customerId } } } return false } export class CheckoutError extends Error { couponId?: string productId: string constructor(message: string, productId: string, couponId?: string) { super(message) this.name = 'CheckoutError' this.couponId = couponId this.productId = productId } } const buildCouponNameWithProductName = ( pre: string, productName: string, post: string, ): string => { // Calculate the total length without the ellipsis const totalLength = pre.length + productName.length + post.length // If total length exceeds 40 characters if (totalLength > 40) { // Calculate the number of characters to truncate from productName const excess = totalLength - 40 + 3 // 3 is for the length of ellipsis "..." productName = productName.slice(0, -excess) + '...' } // Return the concatenated string return pre + productName + post } const buildCouponName = ( upgradeFromPurchase: | (Purchase & { product: Product | null }) | null, productId: string, availableUpgrade: UpgradableProduct | null | undefined, purchaseWillBeRestricted: boolean, stripeCouponPercentOff: number, ) => { let couponName = null if ( upgradeFromPurchase?.status === 'Restricted' && !purchaseWillBeRestricted && upgradeFromPurchase.productId === productId ) { // if its the same productId and we are going from PPP to Unrestricted couponName = 'Unrestricted' } else if (availableUpgrade && upgradeFromPurchase?.status === 'Valid') { // if there is an availableUpgrade (e.g. Core -> Bundle) and the original purchase wasn't region restricted couponName = buildCouponNameWithProductName( 'Upgrade from ', upgradeFromPurchase.product?.name || '', '', ) } else if ( availableUpgrade && upgradeFromPurchase?.status === 'Restricted' && purchaseWillBeRestricted ) { // if there is an availableUpgrade (e.g. Core -> Bundle) and we are staying PPP // betterCouponName = `Upgrade from ${ // upgradeFromPurchase.product.name // } + PPP ${stripeCouponPercentOff * 100}% off` couponName = buildCouponNameWithProductName( 'Upgrade from ', upgradeFromPurchase.product?.name || '', ` + PPP ${Math.floor(stripeCouponPercentOff * 100)}% off`, ) } else if ( availableUpgrade && upgradeFromPurchase?.status === 'Restricted' && !purchaseWillBeRestricted ) { // if there is an availableUpgrade (e.g. Core -> Bundle) and we are going from PPP to Unrestricted // couponName = `Unrestricted Upgrade from ${upgradeFromPurchase.product.name}` couponName = buildCouponNameWithProductName( 'Unrestricted Upgrade from ', upgradeFromPurchase.product?.name || '', '', ) } else { // we don't expect to hit this case couponName = 'Discount' } return couponName } const LoadedProductSchema = z.object({ id: z.string(), }) export async function stripeCheckout({ params, config, adapter, }: { params: CheckoutParams config: PaymentsProviderConsumerConfig adapter?: CourseBuilderAdapter }): Promise<any> { try { if (!adapter) { throw new Error('Adapter is required') } const ip_address = params.ip_address let errorRedirectUrl: string | undefined = undefined try { const { productId, quantity: queryQuantity = 1, couponId, userId, upgradeFromPurchaseId, bulk = false, usedCouponId, } = params errorRedirectUrl = config.errorRedirectUrl const cancelUrl = params.cancelUrl || config.cancelUrl const quantity = Number(queryQuantity) const user = userId ? await adapter.getUser?.(userId as string) : false console.log('user', user) const upgradeFromPurchase = upgradeFromPurchaseId ? await adapter.getPurchase(upgradeFromPurchaseId) : null const availableUpgrade = quantity === 1 && upgradeFromPurchase ? await adapter.getUpgradableProducts({ upgradableFromId: upgradeFromPurchase.productId, upgradableToId: productId as string, }) : null const customerId = user ? await findOrCreateStripeCustomerId( user.id, adapter, config.paymentsAdapter, ) : false console.log('customerId', customerId) const loadedProduct = await adapter.getProduct(productId) const result = LoadedProductSchema.safeParse(loadedProduct) if (!result.success) { const errorMessages = result.error.errors .map((err) => err.message) .join(', ') // Send `errorMessages` to Sentry so we can deal with it right away. console.error(`No product (${productId}) was found (${errorMessages})`) throw new CheckoutError( `No product was found`, String(loadedProduct?.id), couponId as string, ) } const loadedProductData = result.data const merchantProduct = await adapter.getMerchantProductForProductId( loadedProductData.id, ) const merchantProductIdentifier = merchantProduct?.identifier if (!merchantProduct) { throw new Error('No merchant product found') } const merchantPrice = await adapter.getMerchantPriceForProductId( merchantProduct.id, ) const merchantPriceIdentifier = merchantPrice?.identifier if (!merchantPriceIdentifier || !merchantProductIdentifier) { throw new Error('No merchant price or product found') } const stripePrice = await config.paymentsAdapter.getPrice( merchantPriceIdentifier, ) const isRecurring = stripePrice?.recurring const merchantCoupon = couponId ? await adapter.getMerchantCoupon(couponId as string) : null const stripeCouponPercentOff = merchantCoupon && merchantCoupon.identifier ? await config.paymentsAdapter.getCouponPercentOff( merchantCoupon.identifier, ) : 0 let discounts = [] let appliedPPPStripeCouponId: string | undefined | null = undefined let upgradedFromPurchaseId: string | undefined | null = undefined const isUpgrade = Boolean( (availableUpgrade || upgradeFromPurchase?.status === 'Restricted') && upgradeFromPurchase, ) const TWELVE_FOUR_HOURS_FROM_NOW = Math.floor( add(new Date(), { hours: 12 }).getTime() / 1000, ) if (isUpgrade && upgradeFromPurchase && loadedProduct && customerId) { const purchaseWillBeRestricted = merchantCoupon?.type === 'ppp' appliedPPPStripeCouponId = merchantCoupon?.identifier upgradedFromPurchaseId = upgradeFromPurchase.id const fixedDiscountForIndividualUpgrade = await getFixedDiscountForIndividualUpgrade({ purchaseToBeUpgraded: upgradeFromPurchase, productToBePurchased: loadedProduct, purchaseWillBeRestricted, userId, ctx: adapter, }) const productPrice = await adapter.getPriceForProduct(loadedProduct.id) const fullPrice = productPrice?.unitAmount || 0 const calculatedPrice = getCalculatedPrice({ unitPrice: fullPrice, percentOfDiscount: stripeCouponPercentOff, quantity: 1, fixedDiscount: fixedDiscountForIndividualUpgrade, }) const upgradeFromProduct = await adapter.getProduct( upgradeFromPurchase.productId, ) if (fixedDiscountForIndividualUpgrade > 0) { const couponName = buildCouponName( { ...upgradeFromPurchase, product: upgradeFromProduct }, productId, first(availableUpgrade), purchaseWillBeRestricted, stripeCouponPercentOff, ) const amount_off_in_cents = (fullPrice - calculatedPrice) * 100 const couponId = await config.paymentsAdapter.createCoupon({ amount_off: amount_off_in_cents, name: couponName, max_redemptions: 1, redeem_by: TWELVE_FOUR_HOURS_FROM_NOW, currency: 'USD', applies_to: { products: [merchantProductIdentifier], }, }) discounts.push({ coupon: couponId, }) } } else if (merchantCoupon && merchantCoupon.identifier) { // no ppp for bulk purchases const isNotPPP = merchantCoupon.type !== 'ppp' if (isNotPPP || quantity === 1) { appliedPPPStripeCouponId = merchantCoupon.type === 'ppp' ? merchantCoupon?.identifier : undefined const promotionCodeId = await config.paymentsAdapter.createPromotionCode({ coupon: merchantCoupon.identifier, max_redemptions: 1, expires_at: TWELVE_FOUR_HOURS_FROM_NOW, }) discounts.push({ promotion_code: promotionCodeId, }) } } if (!loadedProduct) { throw new Error('No product was found') } let successUrl: string = (() => { const baseQueryParams = { session_id: '{CHECKOUT_SESSION_ID}', provider: 'stripe', } if (isRecurring) { const queryParamString = buildSearchParams(baseQueryParams) return `${config.baseSuccessUrl}/thanks/subscription?${queryParamString}` } if (isUpgrade) { const queryParamString = buildSearchParams({ ...baseQueryParams, upgrade: 'true', }) return `${config.baseSuccessUrl}/welcome?${queryParamString}` } else { const queryParamString = buildSearchParams(baseQueryParams) return `${config.baseSuccessUrl}/thanks/purchase?${queryParamString}` } })() const metadata = CheckoutSessionMetadataSchema.parse({ ...(Boolean(availableUpgrade && upgradeFromPurchase) && { upgradeFromPurchaseId: upgradeFromPurchaseId as string, }), bulk: Boolean(bulk) ? 'true' : quantity > 1 ? 'true' : 'false', ...(appliedPPPStripeCouponId && { appliedPPPStripeCouponId }), ...(upgradedFromPurchaseId && { upgradedFromPurchaseId }), country: params.country || process.env.DEFAULT_COUNTRY || 'US', ip_address: ip_address || '', ...(usedCouponId && { usedCouponId }), productId: loadedProduct.id, product: loadedProduct.name, ...(user && { userId: user.id }), siteName: process.env.NEXT_PUBLIC_APP_NAME as string, ...(params.organizationId && { organizationId: params.organizationId }), }) const sessionUrl = await config.paymentsAdapter.createCheckoutSession({ discounts, line_items: [ { price: merchantPriceIdentifier, quantity: Number(quantity), }, ], expires_at: TWELVE_FOUR_HOURS_FROM_NOW, mode: isRecurring ? 'subscription' : 'payment', success_url: successUrl, cancel_url: cancelUrl, ...(isRecurring ? customerId ? { customer: customerId } : user && { customer_email: user.email } : customerId ? { customer: customerId } : { customer_creation: 'always' }), metadata, ...(!isRecurring && { payment_intent_data: { metadata, }, }), }) if (sessionUrl) { return { redirect: sessionUrl, status: 303, } } else { throw new CheckoutError( 'no-stripe-session', loadedProduct.id, couponId as string, ) } } catch (err: any) { console.error('err', err) if (errorRedirectUrl) { return { redirect: errorRedirectUrl, status: 303, } } return { status: 500, body: { error: true, message: err.message }, } } } catch (error: any) { return { status: 500, body: { error: true, message: error.message }, } } }