diff --git a/apps/testing-javascript/src/pages/api/skill/[...skillRecordings].ts b/apps/testing-javascript/src/pages/api/skill/[...skillRecordings].ts index 2c66a8d73..651184a06 100644 --- a/apps/testing-javascript/src/pages/api/skill/[...skillRecordings].ts +++ b/apps/testing-javascript/src/pages/api/skill/[...skillRecordings].ts @@ -7,7 +7,7 @@ import { } from '@skillrecordings/commerce-server' import {nextAuthOptions} from '../auth/[...nextauth]' -const paymentOptions = defaultPaymentOptions({ +export const paymentOptions = defaultPaymentOptions({ stripeProvider: StripeProvider({ stripeSecretKey: process.env.STRIPE_SECRET_TOKEN, apiVersion: '2020-08-27', diff --git a/apps/testing-javascript/src/pages/thanks/purchase.tsx b/apps/testing-javascript/src/pages/thanks/purchase.tsx index 0120b745e..510a8c8ae 100644 --- a/apps/testing-javascript/src/pages/thanks/purchase.tsx +++ b/apps/testing-javascript/src/pages/thanks/purchase.tsx @@ -5,7 +5,6 @@ import { convertToSerializeForNextResponse, determinePurchaseType, type PurchaseType, - stripeData, } from '@skillrecordings/commerce-server' import type {SanityProduct} from '@skillrecordings/commerce-server/dist/@types' import { @@ -23,32 +22,34 @@ import {getAllProducts} from '@/server/products.server' import {type SanityDocument} from '@sanity/client' import {InvoiceCard} from '@/pages/invoices' import {MailIcon} from '@heroicons/react/solid' +import {paymentOptions} from '../api/skill/[...skillRecordings]' export const getServerSideProps: GetServerSideProps = async (context) => { const {query} = context - const {session_id} = query + const session_id = + query.session_id instanceof Array ? query.session_id[0] : query.session_id - if (!session_id) { + const paymentProvider = paymentOptions.providers.stripe + + if (!session_id || !paymentProvider) { return { notFound: true, } } - const purchaseInfo = await stripeData({ - checkoutSessionId: session_id as string, - }) + const purchaseInfo = await paymentProvider.getPurchaseInfo(session_id) const { email, - stripeChargeId, + chargeIdentifier, quantity: seatsPurchased, - stripeProduct, + product, } = purchaseInfo - const stripeProductName = stripeProduct.name + const stripeProductName = product.name - const purchase = await getSdk().getPurchaseForStripeCharge(stripeChargeId) + const purchase = await getSdk().getPurchaseForStripeCharge(chargeIdentifier) if (!purchase || !email) { return { @@ -57,7 +58,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { } const purchaseType = await determinePurchaseType({ - checkoutSessionId: session_id as string, + checkoutSessionId: session_id, }) const products = await getAllProducts() diff --git a/apps/testing-javascript/src/pages/welcome/index.tsx b/apps/testing-javascript/src/pages/welcome/index.tsx index 78ba743e0..e8eb3ddd6 100644 --- a/apps/testing-javascript/src/pages/welcome/index.tsx +++ b/apps/testing-javascript/src/pages/welcome/index.tsx @@ -1,8 +1,5 @@ import * as React from 'react' -import { - convertToSerializeForNextResponse, - stripeData, -} from '@skillrecordings/commerce-server' +import {convertToSerializeForNextResponse} from '@skillrecordings/commerce-server' import type {SanityProduct} from '@skillrecordings/commerce-server/dist/@types' import {useSession} from 'next-auth/react' import {type GetServerSideProps} from 'next' @@ -17,22 +14,26 @@ import {getAllProducts} from '@/server/products.server' import Image from 'next/legacy/image' import {trpc} from '../../trpc/trpc.client' import {Transfer} from '@/purchase-transfer/purchase-transfer' +import {paymentOptions} from '../api/skill/[...skillRecordings]' export const getServerSideProps: GetServerSideProps = async ({req, query}) => { - const {purchaseId: purchaseQueryParam, session_id, upgrade} = query + const {purchaseId: purchaseQueryParam, upgrade} = query + const session_id = + query.session_id instanceof Array ? query.session_id[0] : query.session_id const token = await getToken({req}) const {getPurchaseDetails} = getSdk() + const paymentProvider = paymentOptions.providers.stripe + let purchaseId = purchaseQueryParam - if (session_id) { - const {stripeChargeId} = await stripeData({ - checkoutSessionId: session_id as string, - }) + if (session_id && paymentProvider) { + const {chargeIdentifier} = await paymentProvider.getPurchaseInfo(session_id) + const purchase = await prisma.purchase.findFirst({ where: { merchantCharge: { - identifier: stripeChargeId, + identifier: chargeIdentifier, }, }, }) diff --git a/packages/commerce-server/src/index.ts b/packages/commerce-server/src/index.ts index 35fc703e1..25fe10f2a 100644 --- a/packages/commerce-server/src/index.ts +++ b/packages/commerce-server/src/index.ts @@ -9,4 +9,4 @@ export * from './props-for-commerce' export * from './record-new-purchase' export * from './determine-purchase-type' export * from './get-valid-purchases' -export * from './providers/default-payment-options' +export * from './providers' diff --git a/packages/commerce-server/src/providers/default-payment-options.ts b/packages/commerce-server/src/providers/default-payment-options.ts index 1dab70d24..d597cfe33 100644 --- a/packages/commerce-server/src/providers/default-payment-options.ts +++ b/packages/commerce-server/src/providers/default-payment-options.ts @@ -1,12 +1,50 @@ import Stripe from 'stripe' +import {z} from 'zod' type StripeConfig = { stripeSecretKey: string apiVersion: '2020-08-27' } -type StripeProvider = {name: 'stripe'; paymentClient: Stripe} -type StripeProviderFunction = (options: StripeConfig) => StripeProvider +export const PurchaseMetadata = z.object({ + country: z.string().optional(), + appliedPPPStripeCouponId: z.string().optional(), // TODO: make this provider agnostic + upgradedFromPurchaseId: z.string().optional(), + usedCouponId: z.string().optional(), +}) + +export const PurchaseInfoSchema = z.object({ + customerIdentifier: z.string(), + email: z.string().nullable(), + name: z.string().nullable(), + productIdentifier: z.string(), + product: z.object({name: z.string().nullable()}), // TODO: does this need to surface any other values? + chargeIdentifier: z.string(), + couponIdentifier: z.string().optional(), + quantity: z.number(), + chargeAmount: z.number(), + metadata: PurchaseMetadata.passthrough().optional(), +}) +export type PurchaseInfo = z.infer + +type PaymentProviderFunctionality = { + getPurchaseInfo: (checkoutSessionId: string) => Promise +} + +// This is the main type that represents payment providers to the outside world +export type PaymentProvider = PaymentProviderFunctionality & + ( + | {name: 'stripe'; paymentClient: Stripe} + | {name: 'paypal'; paymentClient: Paypal} + ) + +type StripeProvider = { + name: 'stripe' + paymentClient: Stripe +} & PaymentProviderFunctionality +export type StripeProviderFunction = ( + options: StripeConfig | {defaultStripeClient: Stripe}, +) => StripeProvider type Paypal = 'paypal-client' type PaypalProvider = {name: 'paypal'; paymentClient: Paypal} @@ -23,19 +61,6 @@ export type PaymentOptions = { } } -// define providers here for now, -// but eventually they can go in a `providers/` directory -export const StripeProvider: StripeProviderFunction = (config) => { - const stripeClient = new Stripe(config.stripeSecretKey, { - apiVersion: config.apiVersion, - }) - - return { - name: 'stripe', - paymentClient: stripeClient, - } -} - // Two concepts for the providers: // 1. We have the Payment Provider Functions (factories?) that take a few config values // 2. We have the Payment Provider Options which are the resulting object of the above function diff --git a/packages/commerce-server/src/providers/index.ts b/packages/commerce-server/src/providers/index.ts new file mode 100644 index 000000000..0ca0d8779 --- /dev/null +++ b/packages/commerce-server/src/providers/index.ts @@ -0,0 +1,2 @@ +export * from './default-payment-options' +export * from './stripe-provider' diff --git a/packages/commerce-server/src/providers/stripe-provider.ts b/packages/commerce-server/src/providers/stripe-provider.ts new file mode 100644 index 000000000..93085571c --- /dev/null +++ b/packages/commerce-server/src/providers/stripe-provider.ts @@ -0,0 +1,64 @@ +import {getStripeSdk} from '@skillrecordings/stripe-sdk' +import {first} from 'lodash' +import Stripe from 'stripe' +import { + StripeProviderFunction, + PurchaseMetadata, + PurchaseInfo, + PurchaseInfoSchema, +} from './default-payment-options' + +export const StripeProvider: StripeProviderFunction = (config) => { + const stripeClient = + 'defaultStripeClient' in config + ? config.defaultStripeClient + : new Stripe(config.stripeSecretKey, { + apiVersion: config.apiVersion, + }) + + const getStripePurchaseInfo = async (checkoutSessionId: string) => { + const {getCheckoutSession} = getStripeSdk({ctx: {stripe: stripeClient}}) + + const checkoutSession = await getCheckoutSession(checkoutSessionId) + + const {customer, line_items, payment_intent, metadata} = checkoutSession + const {email, name, id: stripeCustomerId} = customer as Stripe.Customer + const lineItem = first(line_items?.data) as Stripe.LineItem + const stripePrice = lineItem.price + const quantity = lineItem.quantity || 1 + const stripeProduct = stripePrice?.product as Stripe.Product + const {charges} = payment_intent as Stripe.PaymentIntent + const stripeCharge = first(charges.data) + const stripeChargeId = stripeCharge?.id as string + const stripeChargeAmount = stripeCharge?.amount || 0 + + // extract MerchantCoupon identifier if used for purchase + const discount = first(lineItem.discounts) + const stripeCouponId = discount?.discount.coupon.id + + const parsedMetadata = metadata + ? PurchaseMetadata.parse(metadata) + : undefined + + const info: PurchaseInfo = { + customerIdentifier: stripeCustomerId, + email, + name, + productIdentifier: stripeProduct.id, + product: stripeProduct, + chargeIdentifier: stripeChargeId, + couponIdentifier: stripeCouponId, + quantity, + chargeAmount: stripeChargeAmount, + metadata: parsedMetadata, + } + + return PurchaseInfoSchema.parse(info) + } + + return { + name: 'stripe', + paymentClient: stripeClient, + getPurchaseInfo: getStripePurchaseInfo, + } +} diff --git a/packages/commerce-server/src/record-new-purchase.ts b/packages/commerce-server/src/record-new-purchase.ts index 32cbeed23..f1481ea25 100644 --- a/packages/commerce-server/src/record-new-purchase.ts +++ b/packages/commerce-server/src/record-new-purchase.ts @@ -7,6 +7,10 @@ import { } from '@skillrecordings/stripe-sdk' import {NEW_INDIVIDUAL_PURCHASE} from '@skillrecordings/types' import {determinePurchaseType, PurchaseType} from './determine-purchase-type' +import { + PaymentProvider, + PurchaseInfo, +} from './providers/default-payment-options' export const NO_ASSOCIATED_PRODUCT = 'no-associated-product' export class PurchaseError extends Error { @@ -68,24 +72,9 @@ export async function stripeData(options: StripeDataOptions) { } } -export type PurchaseInfo = { - stripeCustomerId: string - email: string | null - name: string | null - stripeProductId: string - stripeChargeId: string - quantity: number - stripeChargeAmount: number - stripeProduct: Stripe.Product -} - -type Options = { - stripeCtx?: StripeContext -} - export async function recordNewPurchase( checkoutSessionId: string, - options: Options, + options: {paymentProvider: PaymentProvider}, ): Promise<{ user: any purchase: Purchase @@ -99,19 +88,19 @@ export async function recordNewPurchase( getMerchantProduct, } = getSdk() - const {stripeCtx} = options - - const purchaseInfo = await stripeData({checkoutSessionId, stripeCtx}) + const purchaseInfo = await options.paymentProvider.getPurchaseInfo( + checkoutSessionId, + ) const { - stripeCustomerId, + customerIdentifier, email, name, - stripeProductId, - stripeChargeId, - stripeCouponId, + productIdentifier, + chargeIdentifier, + couponIdentifier, quantity, - stripeChargeAmount, + chargeAmount, metadata, } = purchaseInfo @@ -119,32 +108,32 @@ export async function recordNewPurchase( const {user, isNewUser} = await findOrCreateUser(email, name) - const merchantProduct = await getMerchantProduct(stripeProductId) + const merchantProduct = await getMerchantProduct(productIdentifier) if (!merchantProduct) throw new PurchaseError( NO_ASSOCIATED_PRODUCT, checkoutSessionId, email, - stripeProductId, + productIdentifier, ) const {id: merchantProductId, productId, merchantAccountId} = merchantProduct const {id: merchantCustomerId} = await findOrCreateMerchantCustomer({ user: user, - identifier: stripeCustomerId, + identifier: customerIdentifier, merchantAccountId, }) const purchase = await createMerchantChargeAndPurchase({ userId: user.id, - stripeChargeId, - stripeCouponId, + stripeChargeId: chargeIdentifier, + stripeCouponId: couponIdentifier, merchantAccountId, merchantProductId, merchantCustomerId, productId, - stripeChargeAmount, + stripeChargeAmount: chargeAmount, quantity, bulk: metadata?.bulk === 'true', country: metadata?.country, diff --git a/packages/skill-api/src/core/services/process-stripe-webhook.ts b/packages/skill-api/src/core/services/process-stripe-webhook.ts index 006756901..0466c7707 100644 --- a/packages/skill-api/src/core/services/process-stripe-webhook.ts +++ b/packages/skill-api/src/core/services/process-stripe-webhook.ts @@ -4,9 +4,12 @@ import {getSdk, prisma} from '@skillrecordings/database' import { recordNewPurchase, NO_ASSOCIATED_PRODUCT, + StripeProvider, + defaultPaymentOptions, } from '@skillrecordings/commerce-server' import {buffer} from 'micro' -import {postSaleToSlack, sendServerEmail} from '../../server' +import {postSaleToSlack, sendServerEmail} from '../../server' // TODO: add import path helper to tsconfig +import type {PaymentOptions} from '@skillrecordings/commerce-server' import {convertkitTagPurchase} from './convertkit' import {Inngest} from 'inngest' import { @@ -26,7 +29,19 @@ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET const METADATA_MISSING_SITE_NAME = 'metadata-missing-site-name' -type PaymentOptions = {stripeCtx: {stripe: Stripe}} +// type PaymentOptions = {stripeCtx: {stripe: Stripe}} + +const getStripeClient = (paymentOptions: PaymentOptions | undefined) => { + return paymentOptions?.providers.stripe?.paymentClient +} + +const constructFallbackStripePaymentOptions = ( + stripe: Stripe, +): PaymentOptions => { + return defaultPaymentOptions({ + stripeProvider: StripeProvider({defaultStripeClient: stripe}), + }) +} export async function receiveInternalStripeWebhooks({ params, @@ -52,9 +67,8 @@ export async function receiveInternalStripeWebhooks({ } } - const _paymentOptions = paymentOptions || { - stripeCtx: {stripe: defaultStripe}, - } + const _paymentOptions = + paymentOptions || constructFallbackStripePaymentOptions(defaultStripe) const event = req.body.event @@ -132,10 +146,9 @@ export async function receiveStripeWebhooks({ } } - const _paymentOptions = paymentOptions || { - stripeCtx: {stripe: defaultStripe}, - } - const stripe = paymentOptions?.stripeCtx.stripe || defaultStripe + const _paymentOptions = + paymentOptions || constructFallbackStripePaymentOptions(defaultStripe) + const stripe = getStripeClient(paymentOptions) || defaultStripe if (!stripe) { throw new Error('Stripe client is missing') @@ -232,6 +245,14 @@ export const processStripeWebhook = async ( ) => { const {paymentOptions, nextAuthOptions} = options + const stripeProvider = paymentOptions.providers.stripe + + if (!stripeProvider) { + throw new Error( + 'Stripe Provider must be configured to process Stripe webhooks', + ) + } + const eventType: string = event.type const stripeIdentifier: string = event.data.object.id const eventObject = event.data.object @@ -245,7 +266,7 @@ export const processStripeWebhook = async ( if (eventType === 'checkout.session.completed') { const {user, purchase, purchaseInfo} = await recordNewPurchase( stripeIdentifier, - paymentOptions, + {paymentProvider: stripeProvider}, ) if (!user) throw new Error('no-user-created') diff --git a/packages/skill-api/src/router.ts b/packages/skill-api/src/router.ts index 7aa5ed704..f6a1b122c 100644 --- a/packages/skill-api/src/router.ts +++ b/packages/skill-api/src/router.ts @@ -86,17 +86,20 @@ export async function actionRouter({ case 'stripe': return await receiveStripeWebhooks({ params, - paymentOptions, + paymentOptions: userOptions.paymentOptions, }) case 'stripe-internal': return await receiveInternalStripeWebhooks({ params, - paymentOptions, + paymentOptions: userOptions.paymentOptions, }) case 'sanity': return await processSanityWebhooks({params}) } - return await receiveStripeWebhooks({params, paymentOptions}) + return await receiveStripeWebhooks({ + params, + paymentOptions: userOptions.paymentOptions, + }) case 'subscribe': return await subscribeToConvertkit({params}) case 'answer': @@ -108,9 +111,9 @@ export async function actionRouter({ case 'nameUpdate': return await updateName({params}) case 'transfer': - return await transferPurchase({params, paymentOptions}) + return await transferPurchase({params, paymentOptions}) // update this to PaymentOptions case 'refund': - return await stripeRefund({params, paymentOptions}) + return await stripeRefund({params, paymentOptions}) // update this to PaymentOptions case 'create-magic-link': return await createMagicLink({params}) } diff --git a/packages/skill-api/src/server/post-to-slack.ts b/packages/skill-api/src/server/post-to-slack.ts index ab00d947e..0001fd18a 100644 --- a/packages/skill-api/src/server/post-to-slack.ts +++ b/packages/skill-api/src/server/post-to-slack.ts @@ -116,21 +116,21 @@ export async function postSaleToSlack( channel: process.env.SLACK_ANNOUNCE_CHANNEL_ID, text: process.env.NODE_ENV === 'production' - ? `Someone purchased ${purchaseInfo.stripeProduct.name}` - : `Someone purchased ${purchaseInfo.stripeProduct.name} in ${process.env.NODE_ENV}`, + ? `Someone purchased ${purchaseInfo.product.name}` + : `Someone purchased ${purchaseInfo.product.name} in ${process.env.NODE_ENV}`, attachments: [ { - fallback: `Sold (${purchaseInfo.quantity}) ${purchaseInfo.stripeProduct.name}`, + fallback: `Sold (${purchaseInfo.quantity}) ${purchaseInfo.product.name}`, text: `Somebody (${purchaseInfo.email}) bought ${ purchaseInfo.quantity } ${pluralize('copy', purchaseInfo.quantity)} of ${ - purchaseInfo.stripeProduct.name + purchaseInfo.product.name } for ${`$${purchase.totalAmount}`}${ isEmpty(purchase.upgradedFromId) ? '' : ' as an upgrade' }`, color: process.env.NODE_ENV === 'production' ? '#eba234' : '#5ceb34', - title: `Sold (${purchaseInfo.quantity}) ${purchaseInfo.stripeProduct.name}`, + title: `Sold (${purchaseInfo.quantity}) ${purchaseInfo.product.name}`, }, ], })