-
Notifications
You must be signed in to change notification settings - Fork 22
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
Conversation
joelhooks
commented
Apr 19, 2024
migrated the pricing widget and styles - build is still broken, but getting close just needed to git this stuff committed adds local mysql support via docker compose
added adapter pieces to load the required data
also casually update to latest next canary
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
…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 |
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 const emailProvider = EmailProvider({ | ||
server: { | ||
host: env.EMAIL_SERVER_HOST, | ||
port: env.EMAIL_SERVER_PORT, | ||
auth: { | ||
user: env.POSTMARK_KEY, | ||
pass: env.POSTMARK_KEY, | ||
}, | ||
}, | ||
}) |
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.
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 { |
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.
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
@@ -222,7 +221,7 @@ export function mySqlDrizzleAdapter( | |||
client, | |||
createMerchantCustomer: async (options) => { | |||
await client.insert(merchantCustomer).values({ | |||
id: v4(), | |||
id: `mc_${v4()}`, |
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.
having the prefix is super handy
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, | ||
}) | ||
}) | ||
} |
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.
this turned out very nice/clean via the adapters
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', | ||
} |
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.
for reference and probably mocks in the future
// 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 } : {}), | ||
} |
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.
this was annoying so i punted otherwise merge
hits the call stack limit via recursion
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 | ||
} | ||
|
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.
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 💀
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 | ||
} |
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.
the one where joel tries to figure out body parsing errors
…ro-aws # Conflicts: # pnpm-lock.yaml