Skip to content

Commit

Permalink
feat: add getPurchaseInfo to Payment providers
Browse files Browse the repository at this point in the history
This is the first part of adding a functional interface to payment
providers so that other packages and apps can generically do payments
actions.
  • Loading branch information
jbranchaud authored and kodiakhq[bot] committed Mar 22, 2024
1 parent 75ce204 commit 35af7be
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 57 deletions.
98 changes: 91 additions & 7 deletions packages/commerce-server/src/providers/default-payment-options.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,52 @@
import {getStripeSdk} from '@skillrecordings/stripe-sdk'
import {first} from 'lodash'
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
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(),
})

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<typeof PurchaseInfoSchema>

type PaymentProviderFunctionality = {
getPurchaseInfo: (checkoutSessionId: string) => Promise<PurchaseInfo>
}

// 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
type StripeProviderFunction = (
options: StripeConfig | {defaultStripeClient: Stripe},
) => StripeProvider

type Paypal = 'paypal-client'
type PaypalProvider = {name: 'paypal'; paymentClient: Paypal}
Expand All @@ -23,16 +63,60 @@ export type PaymentOptions = {
}
}

// define providers here for now,
// but eventually they can go in a `providers/` directory
// TODO: this should have a shared PaymentProvider type that all providers conform to
// TODO: this can eventually move to it's own Stripe module in `providers/`
export const StripeProvider: StripeProviderFunction = (config) => {
const stripeClient = new Stripe(config.stripeSecretKey, {
apiVersion: config.apiVersion,
})
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<Stripe.Charge>(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,
}
}

Expand Down
49 changes: 19 additions & 30 deletions packages/commerce-server/src/record-new-purchase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -99,52 +88,52 @@ 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

if (!email) throw new PurchaseError(`no-email`, checkoutSessionId)

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,
Expand Down
41 changes: 31 additions & 10 deletions packages/skill-api/src/core/services/process-stripe-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -52,9 +67,8 @@ export async function receiveInternalStripeWebhooks({
}
}

const _paymentOptions = paymentOptions || {
stripeCtx: {stripe: defaultStripe},
}
const _paymentOptions =
paymentOptions || constructFallbackStripePaymentOptions(defaultStripe)

const event = req.body.event

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand All @@ -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')
Expand Down
13 changes: 8 additions & 5 deletions packages/skill-api/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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})
}
Expand Down
10 changes: 5 additions & 5 deletions packages/skill-api/src/server/post-to-slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
},
],
})
Expand Down

0 comments on commit 35af7be

Please sign in to comment.