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: record a complete roundtrip stripe purchase #156

Merged
merged 40 commits into from
Apr 24, 2024

Conversation

joelhooks
Copy link
Collaborator

spiral

Copy link

vercel bot commented Apr 19, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
course-builder-docs ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 24, 2024 4:00pm
course-builder-poc ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 24, 2024 4:00pm
pro-aws ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 24, 2024 4:00pm

…ro-aws

# Conflicts:
#	apps/course-builder-web/src/app/api/coursebuilder/[...nextCourseBuilder]/route.ts
#	apps/course-builder-web/src/coursebuilder/course-builder-config.ts
#	apps/course-builder-web/src/coursebuilder/stripe-payment-adapter.ts
#	apps/course-builder-web/src/db/index.ts
#	apps/course-builder-web/src/env.mjs
#	packages/adapter-drizzle/src/lib/mysql/index.ts
#	packages/core/src/lib/actions/index.ts
#	packages/core/src/lib/index.ts
#	packages/core/src/providers/index.ts
#	packages/core/src/providers/stripe.ts
#	packages/core/src/types.ts
#	pnpm-lock.yaml
@@ -3,5 +3,3 @@ import { withSkill } from '@/server/with-skill'

export const GET = withSkill(authGet)
export const POST = withSkill(authPost)

export const runtime = 'edge' // optional
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🫡

Comment on lines +4 to +13
export const emailProvider = EmailProvider({
server: {
host: env.EMAIL_SERVER_HOST,
port: env.EMAIL_SERVER_PORT,
auth: {
user: env.POSTMARK_KEY,
pass: env.POSTMARK_KEY,
},
},
})
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

haven't used this yet, but that's the next step to process post-purchase

@@ -5,6 +5,7 @@ import { type PaymentsAdapter } from '@coursebuilder/core/types'

export class StripePaymentAdapter implements PaymentsAdapter {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this payment adapter is pretty nice in practice! will be interesting to see how it holds up with additional service providers, but i think it should do well

apps/course-builder-web/src/db/index.ts Show resolved Hide resolved
@@ -222,7 +221,7 @@ export function mySqlDrizzleAdapter(
client,
createMerchantCustomer: async (options) => {
await client.insert(merchantCustomer).values({
id: v4(),
id: `mc_${v4()}`,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

having the prefix is super handy

Comment on lines 306 to 386
const stripeEvent = event.data.stripeEvent
const paymentsAdapter = paymentProvider.options.paymentsAdapter
const stripeCheckoutSession = stripeEvent.data.object

const merchantAccount = await step.run(
'load the merchant account',
async () => {
return await db.getMerchantAccount({
provider: 'stripe',
})
},
)

if (!merchantAccount) {
throw new Error('Merchant account not found')
}

const checkoutSession = await step.run(
'get stripe checkout session',
async () => {
return await paymentsAdapter.getCheckoutSession(
stripeCheckoutSession.id,
)
},
)

const purchaseInfo = await step.run('parse checkout session', async () => {
return await parseCheckoutSession(checkoutSession, db)
})

const { user, isNewUser } = await step.run('load the user', async () => {
if (!purchaseInfo.email) {
throw new Error('purchaseInfo.email is null')
}
return await db.findOrCreateUser(purchaseInfo.email)
})

const merchantProduct = await step.run(
'load the merchant product',
async () => {
return await db.getMerchantProduct(purchaseInfo.productIdentifier)
},
)

const merchantCustomer = await step.run(
'load the merchant customer',
async () => {
return await db.findOrCreateMerchantCustomer({
user: user as User,
identifier: purchaseInfo.customerIdentifier,
merchantAccountId: merchantAccount.id,
})
},
)

return await step.run('create a merchant charge and purchase', async () => {
if (!merchantProduct) {
throw new Error('merchantProduct is null')
}
if (!merchantCustomer) {
throw new Error('merchantCustomer is null')
}
return await db.createMerchantChargeAndPurchase({
userId: user.id,
productId: merchantProduct.productId,
stripeChargeId: purchaseInfo.chargeIdentifier,
stripeCouponId: purchaseInfo.couponIdentifier,
merchantAccountId: merchantAccount.id,
merchantProductId: merchantProduct.id,
merchantCustomerId: merchantCustomer.id,
stripeChargeAmount: purchaseInfo.chargeAmount,
quantity: purchaseInfo.quantity,
checkoutSessionId: stripeCheckoutSession.id,
country: purchaseInfo.metadata?.country,
appliedPPPStripeCouponId:
purchaseInfo.metadata?.appliedPPPStripeCouponId,
upgradedFromPurchaseId: purchaseInfo.metadata?.upgradedFromPurchaseId,
usedCouponId: purchaseInfo.metadata?.usedCouponId,
})
})
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this turned out very nice/clean via the adapters

Comment on lines +11 to +114
created: 1713381550,
data: {
object: {
id: 'cs_test_a1HUHxQCrAdyfBpHFGeL6Rg5gmTWK7f4hN09zB9Bk8cJBtWDLTYZeMCyqP',
object: 'checkout.session',
after_expiration: null,
allow_promotion_codes: null,
amount_subtotal: 1000,
amount_total: 1000,
automatic_tax: { enabled: false, liability: null, status: null },
billing_address_collection: null,
cancel_url:
'https://neatly-diverse-goldfish.ngrok-free.app/checkout-cancel',
client_reference_id: null,
client_secret: null,
consent: null,
consent_collection: null,
created: 1713381530,
currency: 'usd',
currency_conversion: null,
custom_fields: [],
custom_text: {
after_submit: null,
shipping_address: null,
submit: null,
terms_of_service_acceptance: null,
},
customer: 'cus_Pvsd48NJAKWjQ4',
customer_creation: null,
customer_details: {
address: {
city: null,
country: 'US',
line1: null,
line2: null,
postal_code: '42424',
state: null,
},
email: 'joelhooks@gmail.com',
name: '42424',
phone: null,
tax_exempt: 'none',
tax_ids: [],
},
customer_email: null,
expires_at: 1713424729,
invoice: null,
invoice_creation: {
enabled: false,
invoice_data: {
account_tax_ids: null,
custom_fields: null,
description: null,
footer: null,
issuer: null,
metadata: {},
rendering_options: null,
},
},
livemode: false,
locale: null,
metadata: {
siteName: 'inngest-gpt',
userId: '57b0d091-9dfa-4f03-8774-e43f1ec5f3b8',
country: 'US',
productId: 'product_cluq5r0jl000008jp20fbfsgs',
bulk: 'true',
product: 'Test Event Product',
ip_address: '',
},
mode: 'payment',
payment_intent: 'pi_3P6e0MAclagrtXef0vs1HBZt',
payment_link: null,
payment_method_collection: 'always',
payment_method_configuration_details: null,
payment_method_options: { card: { request_three_d_secure: 'automatic' } },
payment_method_types: ['card'],
payment_status: 'paid',
phone_number_collection: { enabled: false },
recovered_from: null,
setup_intent: null,
shipping: null,
shipping_address_collection: null,
shipping_options: [],
shipping_rate: null,
status: 'complete',
submit_type: null,
subscription: null,
success_url:
'https://neatly-diverse-goldfish.ngrok-free.app/checkout-success/thanks/purchase?session_id={CHECKOUT_SESSION_ID}&provider=stripe',
total_details: { amount_discount: 0, amount_shipping: 0, amount_tax: 0 },
ui_mode: 'hosted',
url: null,
},
},
livemode: false,
pending_webhooks: 2,
request: { id: null, idempotency_key: null },
type: 'checkout.session.completed',
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

for reference and probably mocks in the future

Comment on lines +21 to +30
// having the adapter class here made it go into a tailspin

const { paymentsAdapter, ...userOptionsWithPaymentsAdapter } = userOptions

console.log({ userOptionsWithPaymentsAdapter, defaults })

return {
...merge(defaults, userOptionsWithPaymentsAdapter),
...(paymentsAdapter ? { options: paymentsAdapter } : {}),
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this was annoying so i punted otherwise merge hits the call stack limit via recursion

Comment on lines +13 to +33
async function getBody(
req: Request,
config: CourseBuilderConfig,
): Promise<Record<string, any> | undefined> {
const headers = Object.fromEntries(req.headers)
const isStripeWebhook = ['stripe-signature'].every((prop: string) => {
return prop in headers
})

if (isStripeWebhook) {
let parsedBody
const stripeProvider = config.providers.find((p: any) => p.id === 'stripe')
parsedBody = await req.text()
stripeProvider?.options?.paymentsAdapter.verifyWebhookSignature(
parsedBody,
headers['stripe-signature'],
)
parsedBody = JSON.parse(parsedBody)
return parsedBody
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this took forever to figure out because you can't call await response.[text|json]() more than once and for stripe to verify you need the raw body 💀

Comment on lines +33 to +51
const httpHandler = async (req: NextRequest) => {
const stripeHeader = headers().get('stripe-signature')
const isWebhook = ['stripe-signature'].every((prop: string) => {
console.log('prop', prop)
return prop in req.headers
})

// const body = await req.text()

const newReq = reqWithEnvURL(req)

console.log('hah', { isWebhook, stripeHeader, config })
const handler = CourseBuilder(newReq, config)

// const body = await newReq.text()
// console.log('body', body)

return handler
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

the one where joel tries to figure out body parsing errors

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants