diff --git a/apps/badass/process.d.ts b/apps/badass/process.d.ts index b2d0c07e7..0ff9a2bdb 100644 --- a/apps/badass/process.d.ts +++ b/apps/badass/process.d.ts @@ -3,6 +3,7 @@ declare namespace NodeJS { NEXTAUTH_URL: string NEXTAUTH_SECRET: string NEXT_PUBLIC_SITE_TITLE: string + NEXT_PUBLIC_APP_NAME: string NEXT_PUBLIC_HOST: string NEXT_PUBLIC_URL: string NEXT_PUBLIC_SUPPORT_EMAIL: string diff --git a/apps/colt-steele/process.d.ts b/apps/colt-steele/process.d.ts index 8d36cc211..50c024d2a 100644 --- a/apps/colt-steele/process.d.ts +++ b/apps/colt-steele/process.d.ts @@ -3,6 +3,7 @@ declare namespace NodeJS { NEXTAUTH_URL: string NEXTAUTH_SECRET: string NEXT_PUBLIC_SITE_TITLE: string + NEXT_PUBLIC_APP_NAME: string NEXT_PUBLIC_URL: string NEXT_PUBLIC_PARTNER_FIRST_NAME: string NEXT_PUBLIC_PARTNER_LAST_NAME: string diff --git a/apps/devrel-fyi/process.d.ts b/apps/devrel-fyi/process.d.ts index b4e03c1c1..308613047 100644 --- a/apps/devrel-fyi/process.d.ts +++ b/apps/devrel-fyi/process.d.ts @@ -3,6 +3,7 @@ declare namespace NodeJS { NEXTAUTH_URL: string NEXTAUTH_SECRET: string NEXT_PUBLIC_SITE_TITLE: string + NEXT_PUBLIC_APP_NAME: string NEXT_PUBLIC_URL: string NEXT_PUBLIC_PARTNER_FIRST_NAME: string NEXT_PUBLIC_PARTNER_LAST_NAME: string diff --git a/apps/epic-react/process.d.ts b/apps/epic-react/process.d.ts index b4e03c1c1..308613047 100644 --- a/apps/epic-react/process.d.ts +++ b/apps/epic-react/process.d.ts @@ -3,6 +3,7 @@ declare namespace NodeJS { NEXTAUTH_URL: string NEXTAUTH_SECRET: string NEXT_PUBLIC_SITE_TITLE: string + NEXT_PUBLIC_APP_NAME: string NEXT_PUBLIC_URL: string NEXT_PUBLIC_PARTNER_FIRST_NAME: string NEXT_PUBLIC_PARTNER_LAST_NAME: string diff --git a/apps/epic-web/.env.development b/apps/epic-web/.env.development index 2c9b01e42..3cbf19f8f 100644 --- a/apps/epic-web/.env.development +++ b/apps/epic-web/.env.development @@ -46,3 +46,5 @@ SENTRY_IGNORE_API_RESOLUTION_ERROR=1 # Product NEXT_PUBLIC_DEFAULT_PRODUCT_ID=kcd_product_dbf94bf0-66b0-11ee-8c99-0242ac120002 + +TESTING_JAVASCRIPT_INTERNAL_STRIPE_URL='http://localhost:3018/api/skill/webhook/stripe-internal' diff --git a/apps/epic-web/.env.production b/apps/epic-web/.env.production index 56c5cb95f..2a0ae6b92 100644 --- a/apps/epic-web/.env.production +++ b/apps/epic-web/.env.production @@ -47,4 +47,6 @@ NEXT_PUBLIC_SELLING_LIVE=true # Axiom Logging NEXT_PUBLIC_AXIOM_DATASET=epic-web -NEXT_PUBLIC_AXIOM_TOKEN=xaat-db4f77ec-21b0-42e8-b6c5-fd91ce7ce479 \ No newline at end of file +NEXT_PUBLIC_AXIOM_TOKEN=xaat-db4f77ec-21b0-42e8-b6c5-fd91ce7ce479 + +TESTING_JAVASCRIPT_INTERNAL_STRIPE_URL='https://testingjavascript.com/api/skill/webhook/stripe-internal' diff --git a/apps/epic-web/process.d.ts b/apps/epic-web/process.d.ts index 3d01bf888..e55f3a2ef 100644 --- a/apps/epic-web/process.d.ts +++ b/apps/epic-web/process.d.ts @@ -3,6 +3,7 @@ declare namespace NodeJS { NEXTAUTH_URL: string NEXTAUTH_SECRET: string NEXT_PUBLIC_SITE_TITLE: string + NEXT_PUBLIC_APP_NAME: string NEXT_PUBLIC_URL: string NEXT_PUBLIC_PARTNER_FIRST_NAME: string NEXT_PUBLIC_PARTNER_LAST_NAME: string diff --git a/apps/pro-aws/process.d.ts b/apps/pro-aws/process.d.ts index ff0421a44..78175a63f 100644 --- a/apps/pro-aws/process.d.ts +++ b/apps/pro-aws/process.d.ts @@ -3,6 +3,7 @@ declare namespace NodeJS { NEXTAUTH_URL: string NEXTAUTH_SECRET: string NEXT_PUBLIC_SITE_TITLE: string + NEXT_PUBLIC_APP_NAME: string NEXT_PUBLIC_URL: string NEXT_PUBLIC_PARTNER_FIRST_NAME: string NEXT_PUBLIC_PARTNER_LAST_NAME: string diff --git a/apps/pro-nextjs/process.d.ts b/apps/pro-nextjs/process.d.ts index 594fe29a8..9abf4bfee 100644 --- a/apps/pro-nextjs/process.d.ts +++ b/apps/pro-nextjs/process.d.ts @@ -3,6 +3,7 @@ declare namespace NodeJS { NEXTAUTH_URL: string NEXTAUTH_SECRET: string NEXT_PUBLIC_SITE_TITLE: string + NEXT_PUBLIC_APP_NAME: string NEXT_PUBLIC_URL: string NEXT_PUBLIC_PARTNER_FIRST_NAME: string NEXT_PUBLIC_PARTNER_LAST_NAME: string diff --git a/apps/testing-javascript/process.d.ts b/apps/testing-javascript/process.d.ts index 155db79ac..a8695dde8 100644 --- a/apps/testing-javascript/process.d.ts +++ b/apps/testing-javascript/process.d.ts @@ -9,6 +9,7 @@ const envVariables = z.object({ NEXTAUTH_URL: z.string(), NEXTAUTH_SECRET: z.string(), NEXT_PUBLIC_SITE_TITLE: z.string(), + NEXT_PUBLIC_APP_NAME: z.string(), NEXT_PUBLIC_URL: z.string(), NEXT_PUBLIC_PARTNER_FIRST_NAME: z.string(), NEXT_PUBLIC_PARTNER_LAST_NAME: z.string(), diff --git a/apps/total-typescript/.env.development b/apps/total-typescript/.env.development index 464000628..0ec047a46 100644 --- a/apps/total-typescript/.env.development +++ b/apps/total-typescript/.env.development @@ -1,6 +1,7 @@ ## 🛠️ DEVELOPMENT 🛠️ ## NEXT_PUBLIC_SITE_TITLE="Total TypeScript" +NEXT_PUBLIC_APP_NAME=total-typescript PORT=3016 NEXTAUTH_URL=http://localhost:3016 NEXTAUTH_SECRET=XKmGZcoWOTDEVuPLGBWMkcj3Df9vJTa2Oyh8f4xUMV0= diff --git a/apps/total-typescript/.env.production b/apps/total-typescript/.env.production index 4f884b959..d8c3c883f 100644 --- a/apps/total-typescript/.env.production +++ b/apps/total-typescript/.env.production @@ -1,4 +1,5 @@ NEXT_PUBLIC_SITE_TITLE="Total TypeScript" +NEXT_PUBLIC_APP_NAME=total-typescript NEXT_PUBLIC_HOST=www.totaltypescript.com NEXT_PUBLIC_URL=https://www.totaltypescript.com NEXTAUTH_URL=https://www.totaltypescript.com diff --git a/apps/total-typescript/process.d.ts b/apps/total-typescript/process.d.ts index 03f5134dd..ae4438ee5 100644 --- a/apps/total-typescript/process.d.ts +++ b/apps/total-typescript/process.d.ts @@ -3,6 +3,7 @@ declare namespace NodeJS { NEXTAUTH_URL: string NEXTAUTH_SECRET: string NEXT_PUBLIC_SITE_TITLE: string + NEXT_PUBLIC_APP_NAME: string NEXT_PUBLIC_URL: string NEXT_PUBLIC_PARTNER_FIRST_NAME: string NEXT_PUBLIC_PARTNER_LAST_NAME: string diff --git a/packages/skill-api/process.d.ts b/packages/skill-api/process.d.ts index ab7495ef3..ae2ba39f7 100644 --- a/packages/skill-api/process.d.ts +++ b/packages/skill-api/process.d.ts @@ -5,5 +5,6 @@ declare namespace NodeJS { NEXT_PUBLIC_DEFAULT_PRODUCT_ID: string SLACK_ANNOUNCE_CHANNEL_ID: string STRIPE_WEBHOOK_SECRET: string + NEXT_PUBLIC_APP_NAME: string } } diff --git a/packages/skill-api/src/core/services/process-stripe-webhook.ts b/packages/skill-api/src/core/services/process-stripe-webhook.ts index de1a65725..1bad5e99b 100644 --- a/packages/skill-api/src/core/services/process-stripe-webhook.ts +++ b/packages/skill-api/src/core/services/process-stripe-webhook.ts @@ -17,17 +17,65 @@ import { defaultContext as defaultStripeContext, Stripe, } from '@skillrecordings/stripe-sdk' +import {z} from 'zod' const {stripe: defaultStripe} = defaultStripeContext const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET -export async function processStripeWebhooks({ +const METADATA_MISSING_SITE_NAME = 'metadata-missing-site-name' + +type PaymentOptions = {stripeCtx: {stripe: Stripe}} + +export async function receiveInternalStripeWebhooks({ params, paymentOptions, }: { params: SkillRecordingsHandlerParams - paymentOptions: {stripeCtx: {stripe: Stripe}} | undefined + paymentOptions: PaymentOptions | undefined +}): Promise { + try { + const { + req, + options: {nextAuthOptions}, + } = params + + const skillSecret = req.headers['x-skill-secret'] as string + + if (skillSecret !== process.env.SKILL_SECRET) { + return { + status: 401, + body: { + error: 'Unauthorized', + }, + } + } + + const _paymentOptions = paymentOptions || { + stripeCtx: {stripe: defaultStripe}, + } + const stripe = paymentOptions?.stripeCtx.stripe || defaultStripe + + const event: any = req.body.event + + return await processStripeWebhook(event, { + nextAuthOptions, + paymentOptions: _paymentOptions, + }) + } catch (error: any) { + return { + status: 500, + body: {error: true, message: error.message}, + } + } +} + +export async function receiveStripeWebhooks({ + params, + paymentOptions, +}: { + params: SkillRecordingsHandlerParams + paymentOptions: PaymentOptions | undefined }): Promise { try { const { @@ -55,11 +103,7 @@ export async function processStripeWebhooks({ const sig = req.headers['stripe-signature'] let event: any - const { - updatePurchaseStatusForCharge, - findOrCreateUser, - transferPurchasesToNewUser, - } = getSdk() + try { event = stripe.webhooks.constructEvent(buf, sig as string, webhookSecret) @@ -78,120 +122,44 @@ export async function processStripeWebhooks({ }) } - if (event.type === 'checkout.session.completed') { - const {user, purchase, purchaseInfo} = await recordNewPurchase( - event.data.object.id, - _paymentOptions, - ) + // 1. verify and extract details from Stripe webhook request + // 2. send details to inngest if available + // 3. tell the appropriate app to handle it... + // 4. return a 200 - if (!user) throw new Error('no-user-created') - - const email = user.email as string - - // TODO: Send different email type for upgrades - - if (process.env.INNGEST_EVENT_KEY) { - const inngest = new Inngest({ - id: - process.env.INNGEST_APP_NAME || - process.env.NEXT_PUBLIC_SITE_TITLE || - 'Stripe Handler', - eventKey: process.env.INNGEST_EVENT_KEY, - }) - console.log('sending inngest event') - await inngest.send({ - name: STRIPE_CHECKOUT_COMPLETED_EVENT, - user, - data: { - purchaseId: purchase.id, - quantity: purchaseInfo.quantity, - productId: purchase.productId, - created: purchase.createdAt.getTime(), - }, - }) - } + const {siteName: targetSiteName} = z + .object({siteName: z.string().default(METADATA_MISSING_SITE_NAME)}) + .parse(event.data.object.metadata) - if (nextAuthOptions) { - await sendServerEmail({ - email, - callbackUrl: `${process.env.NEXT_PUBLIC_URL}/welcome?purchaseId=${purchase.id}`, - nextAuthOptions, - type: 'purchase', + if (targetSiteName === 'testing-javascript') { + // send event to TJS processing endpoint + const internalStripeWebhookEndpoint = z + .string() + .parse(process.env.TESTING_JAVASCRIPT_INTERNAL_STRIPE_URL) + const skillSecret = z + .string({ + required_error: 'TJS_SKILL_SECRET must be set in this environemnt', }) - } else { - console.warn('⛔️ not sending email: no nextAuthOptions found') - } - - if (process.env.NODE_ENV === 'production') { - await convertkitTagPurchase(email, purchase) - } - - await postSaleToSlack(purchaseInfo, purchase) + .parse(process.env.TJS_SKILL_SECRET) - return { - status: 200, - body: 'success!', - } - } else if (event.type === 'charge.refunded') { - const chargeId = event.data.object.id - await updatePurchaseStatusForCharge(chargeId, 'Refunded') - return { - status: 200, - body: 'success!', - } - } else if (event.type === 'charge.dispute.created') { - const chargeId = event.data.object.id - await updatePurchaseStatusForCharge(chargeId, 'Disputed') - return { - status: 200, - body: 'success!', - } - } else if (event.type === 'customer.updated') { - const merchantCustomer = await prisma.merchantCustomer.findFirst({ - where: { - identifier: event.data.object.id, - }, - include: { - user: true, - }, + const headers = new Headers({ + 'Content-Type': 'application/json', + 'x-skill-secret': skillSecret, }) - const user = merchantCustomer?.user - - if (user) { - const currentEmail = user.email - const {email, name} = event.data.object - - const {user: updateUser} = await findOrCreateUser(email, name) - - await transferPurchasesToNewUser({ - merchantCustomerId: merchantCustomer.id, - userId: updateUser.id, - }) - - if ( - currentEmail.toLowerCase() !== email.toLowerCase() && - nextAuthOptions - ) { - await sendServerEmail({ - email, - callbackUrl: `${process.env.NEXTAUTH_URL}`, - nextAuthOptions, - }) - } - } else { - console.log(`no user found for customer ${event.data.object.id}`) - } + // not awaiting the fetch so that endpoint can return 200 right away + await fetch(internalStripeWebhookEndpoint, { + method: 'POST', + headers, + body: JSON.stringify({event}), + }) - return { - status: 200, - body: 'success!', - } + return {status: 200, body: `handled by ${targetSiteName}`} } else { - return { - status: 200, - body: 'not handled', - } + return await processStripeWebhook(event, { + nextAuthOptions, + paymentOptions: _paymentOptions, + }) } } catch (err: any) { if (err.message === NO_ASSOCIATED_PRODUCT) { @@ -216,3 +184,142 @@ export async function processStripeWebhooks({ } } } + +type NextAuthOptions = + SkillRecordingsHandlerParams['options']['nextAuthOptions'] + +export const processStripeWebhook = async ( + event: any, + options: { + nextAuthOptions: NextAuthOptions + paymentOptions: PaymentOptions + }, +) => { + const {paymentOptions, nextAuthOptions} = options + + const eventType: string = event.type + const stripeIdentifier: string = event.data.object.id + const eventObject = event.data.object + + const { + updatePurchaseStatusForCharge, + findOrCreateUser, + transferPurchasesToNewUser, + } = getSdk() + + if (eventType === 'checkout.session.completed') { + const {user, purchase, purchaseInfo} = await recordNewPurchase( + stripeIdentifier, + paymentOptions, + ) + + if (!user) throw new Error('no-user-created') + + const email = user.email as string + + // TODO: Send different email type for upgrades + + if (process.env.INNGEST_EVENT_KEY) { + const inngest = new Inngest({ + id: + process.env.INNGEST_APP_NAME || + process.env.NEXT_PUBLIC_SITE_TITLE || + 'Stripe Handler', + eventKey: process.env.INNGEST_EVENT_KEY, + }) + console.log('sending inngest event') + await inngest.send({ + name: STRIPE_CHECKOUT_COMPLETED_EVENT, + user, + data: { + purchaseId: purchase.id, + quantity: purchaseInfo.quantity, + productId: purchase.productId, + created: purchase.createdAt.getTime(), + }, + }) + } + + if (nextAuthOptions) { + await sendServerEmail({ + email, + callbackUrl: `${process.env.NEXT_PUBLIC_URL}/welcome?purchaseId=${purchase.id}`, + nextAuthOptions, + type: 'purchase', + }) + } else { + console.warn('⛔️ not sending email: no nextAuthOptions found') + } + + if (process.env.NODE_ENV === 'production') { + await convertkitTagPurchase(email, purchase) + } + + await postSaleToSlack(purchaseInfo, purchase) + + return { + status: 200, + body: 'success!', + } + } else if (eventType === 'charge.refunded') { + const chargeId = stripeIdentifier + await updatePurchaseStatusForCharge(chargeId, 'Refunded') + return { + status: 200, + body: 'success!', + } + } else if (eventType === 'charge.dispute.created') { + const chargeId = stripeIdentifier + await updatePurchaseStatusForCharge(chargeId, 'Disputed') + return { + status: 200, + body: 'success!', + } + } else if (eventType === 'customer.updated') { + const merchantCustomer = await prisma.merchantCustomer.findFirst({ + where: { + identifier: stripeIdentifier, + }, + include: { + user: true, + }, + }) + + const user = merchantCustomer?.user + + if (user) { + const currentEmail = user.email + const {email, name} = eventObject + + const {user: updateUser} = await findOrCreateUser(email, name) + + await transferPurchasesToNewUser({ + merchantCustomerId: merchantCustomer.id, + userId: updateUser.id, + }) + + if ( + currentEmail.toLowerCase() !== email.toLowerCase() && + nextAuthOptions + ) { + await sendServerEmail({ + email, + callbackUrl: `${process.env.NEXTAUTH_URL}`, + nextAuthOptions, + }) + } + } else { + console.log(`no user found for customer ${stripeIdentifier}`) + } + + return { + status: 200, + body: 'success!', + } + } else { + return { + status: 200, + body: 'not handled', + } + } +} diff --git a/packages/skill-api/src/core/services/stripe-checkout.ts b/packages/skill-api/src/core/services/stripe-checkout.ts index 76b8cd1c2..d1fce3e03 100644 --- a/packages/skill-api/src/core/services/stripe-checkout.ts +++ b/packages/skill-api/src/core/services/stripe-checkout.ts @@ -427,6 +427,7 @@ export async function stripeCheckout({ productId: loadedProduct.id, product: loadedProduct.name, ...(user && {userId: user.id}), + siteName: process.env.NEXT_PUBLIC_APP_NAME, } const session = await stripe.checkout.sessions.create({ diff --git a/packages/skill-api/src/core/services/transfer-purchase.ts b/packages/skill-api/src/core/services/transfer-purchase.ts index 3f71daf29..7988efb66 100644 --- a/packages/skill-api/src/core/services/transfer-purchase.ts +++ b/packages/skill-api/src/core/services/transfer-purchase.ts @@ -114,6 +114,9 @@ export async function transferPurchase({ updateMerchantCustomer.identifier, { email: targetUserEmail, + metadata: { + siteName: process.env.NEXT_PUBLIC_APP_NAME, + }, }, ) } diff --git a/packages/skill-api/src/router.ts b/packages/skill-api/src/router.ts index 106254036..7aa5ed704 100644 --- a/packages/skill-api/src/router.ts +++ b/packages/skill-api/src/router.ts @@ -7,7 +7,10 @@ import {signs3UploadUrl} from './core/services/aws' import {sendFeedbackFromUser} from './core/services/send-feedback-from-user' import {redeemGoldenTicket} from './core/services/redeem-golden-ticket' import {stripeCheckout} from './core/services/stripe-checkout' -import {processStripeWebhooks} from './core/services/process-stripe-webhook' +import { + receiveStripeWebhooks, + receiveInternalStripeWebhooks, +} from './core/services/process-stripe-webhook' import {lookupUser} from './core/services/lookup-user' import {IncomingRequest} from './core' import {claimedSeats} from './core/services/claimed-seats' @@ -36,7 +39,7 @@ export type SkillRecordingsAction = | 'refund' | 'create-magic-link' -export type SkillRecordingsProvider = 'stripe' | 'sanity' +export type SkillRecordingsProvider = 'stripe' | 'stripe-internal' | 'sanity' export async function actionRouter({ method, @@ -81,14 +84,19 @@ export async function actionRouter({ case 'webhook': switch (providerId) { case 'stripe': - return await processStripeWebhooks({ + return await receiveStripeWebhooks({ + params, + paymentOptions, + }) + case 'stripe-internal': + return await receiveInternalStripeWebhooks({ params, paymentOptions, }) case 'sanity': return await processSanityWebhooks({params}) } - return await processStripeWebhooks({params, paymentOptions}) + return await receiveStripeWebhooks({params, paymentOptions}) case 'subscribe': return await subscribeToConvertkit({params}) case 'answer':