Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add getPurchaseInfo to Payment providers #1466

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -7,7 +7,7 @@ import {
} from '@skillrecordings/commerce-server'
import {nextAuthOptions} from '../auth/[...nextauth]'

const paymentOptions = defaultPaymentOptions({
export const paymentOptions = defaultPaymentOptions({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export the paymentOptions so that other pages in TJS can use them

stripeProvider: StripeProvider({
stripeSecretKey: process.env.STRIPE_SECRET_TOKEN,
apiVersion: '2020-08-27',
Expand Down
23 changes: 12 additions & 11 deletions apps/testing-javascript/src/pages/thanks/purchase.tsx
Expand Up @@ -5,7 +5,6 @@ import {
convertToSerializeForNextResponse,
determinePurchaseType,
type PurchaseType,
stripeData,
} from '@skillrecordings/commerce-server'
import type {SanityProduct} from '@skillrecordings/commerce-server/dist/@types'
import {
Expand All @@ -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)
Comment on lines -38 to +41
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace stripeData call with getPurchaseInfo via the payment provider


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 {
Expand All @@ -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()
Expand Down
21 changes: 11 additions & 10 deletions 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'
Expand All @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are still defaulting to stripe here. I need to figure out how we are going to differentiate in general when they land on the welcome page that they were directed here via a Stripe vs Paypal purchase so that we look up purchase info with the correct provider.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have some control over the callback url with stripe so

success_url: `http://yoursite.com/order/success?session_id={CHECKOUT_SESSION_ID}&provider=${provider}`

would probably work

lemonsqueezy has similar (didn't look to closely) https://docs.lemonsqueezy.com/help/products/button-link-variables


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)
Comment on lines -29 to +31
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace stripeData call with getPurchaseInfo via the payment provider


const purchase = await prisma.purchase.findFirst({
where: {
merchantCharge: {
identifier: stripeChargeId,
identifier: chargeIdentifier,
},
},
})
Expand Down
2 changes: 1 addition & 1 deletion packages/commerce-server/src/index.ts
Expand Up @@ -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'
55 changes: 40 additions & 15 deletions 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({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get types flowing for the purchase info

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>
}
Comment on lines +30 to +32
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the first piece of payment functionality moving into the umbrella of payment providers, additional pieces will be added to this type which the different payment providers conform to


// 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}
Expand All @@ -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) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that this is expanding, moving it to its own module

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
Expand Down
2 changes: 2 additions & 0 deletions packages/commerce-server/src/providers/index.ts
@@ -0,0 +1,2 @@
export * from './default-payment-options'
export * from './stripe-provider'
64 changes: 64 additions & 0 deletions 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<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,
}
}
49 changes: 19 additions & 30 deletions packages/commerce-server/src/record-new-purchase.ts
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(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using the payment provider in here to getPurchaseInfo, though you'll notice above that the stripeData function still exists. that is because many of the apps still rely on it. once I've configured payment providers in each of those apps, they will be able to switch over to the new implementation and we can ditch stripeData.

checkoutSessionId,
)

const {
stripeCustomerId,
customerIdentifier,
email,
name,
stripeProductId,
stripeChargeId,
stripeCouponId,
productIdentifier,
chargeIdentifier,
couponIdentifier,
quantity,
stripeChargeAmount,
chargeAmount,
metadata,
Comment on lines 95 to 104
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

account for some variable renaming from purchaseInfo because we are trying this as provider agnostic info

} = 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