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
Changes from all commits
54c1454
2dc1bf6
17c323e
9fb89e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
Comment on lines
-38
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. replace |
||
|
||
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() | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We are still defaulting to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we have some control over the callback url with stripe so
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. replace |
||
|
||
const purchase = await prisma.purchase.findFirst({ | ||
where: { | ||
merchantCharge: { | ||
identifier: stripeChargeId, | ||
identifier: chargeIdentifier, | ||
}, | ||
}, | ||
}) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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} | ||
|
@@ -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) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from './default-payment-options' | ||
export * from './stripe-provider' |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,52 +88,52 @@ export async function recordNewPurchase( | |
getMerchantProduct, | ||
} = getSdk() | ||
|
||
const {stripeCtx} = options | ||
|
||
const purchaseInfo = await stripeData({checkoutSessionId, stripeCtx}) | ||
const purchaseInfo = await options.paymentProvider.getPurchaseInfo( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. using the payment provider in here to |
||
checkoutSessionId, | ||
) | ||
|
||
const { | ||
stripeCustomerId, | ||
customerIdentifier, | ||
email, | ||
name, | ||
stripeProductId, | ||
stripeChargeId, | ||
stripeCouponId, | ||
productIdentifier, | ||
chargeIdentifier, | ||
couponIdentifier, | ||
quantity, | ||
stripeChargeAmount, | ||
chargeAmount, | ||
metadata, | ||
Comment on lines
95
to
104
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. account for some variable renaming from |
||
} = 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, | ||
|
There was a problem hiding this comment.
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