diff --git a/.env.example b/.env.example index e9d82d4..9b3a581 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,4 @@ SLACK_POSTMAN_WEBHOOK_URL= STRIPE_SECRET_KEY= STRIPE_PUBLISHABLE_KEY= +STRIPE_WEBHOOK_SECRET= diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..c9a0b6b --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,59 @@ +import type { NextRequest } from 'next/server'; +import type Stripe from 'stripe'; + +import { env } from '@/env.mjs'; +import { HttpBadRequestError, HttpInternalServerError } from '@/lib/errors'; +import { stripe } from '@/lib/services/stripe/client'; +import { fulfillOrder } from '@/lib/services/stripe/order'; + +export async function POST(req: NextRequest) { + const signature = req.headers.get('stripe-signature'); + + if (!signature) { + return new HttpBadRequestError({ + message: 'Missing Stripe signature', + }).toNextResponse(); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + await req.text(), + signature, + env.STRIPE_WEBHOOK_SECRET, + ); + } catch (error) { + return new HttpBadRequestError({ + message: 'Invalid payload', + }).toNextResponse(); + } + + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = await stripe.checkout.sessions.retrieve( + event.data.object.id, + { + expand: ['line_items'], + }, + ); + + if (session.payment_status === 'paid') { + await fulfillOrder(session); + } + + break; + } + + default: + break; + } + } catch (error) { + return new HttpInternalServerError({ + error, + }).toNextResponse(); + } + + return new Response('OK'); +} diff --git a/app/credits/page.tsx b/app/credits/page.tsx index 20071fd..9861758 100644 --- a/app/credits/page.tsx +++ b/app/credits/page.tsx @@ -1,16 +1,48 @@ import { fetchAccountAICredits } from '@/lib/services/account'; import { getPrices } from '@/lib/services/stripe/prices'; -import { Box, Card, Flex, Heading, Separator, Text } from '@radix-ui/themes'; +import { + CalloutIcon, + CalloutRoot, + CalloutText, + Card, + Flex, + Heading, + Separator, + Text, +} from '@radix-ui/themes'; import { Provider } from 'jotai'; +import { FaCircleCheck } from 'react-icons/fa6'; import { CreditListItem } from './components/credit-list-item'; -export default async function Page() { +type Props = { + searchParams: { + status?: string; + }; +}; + +export default async function Page(props: Props) { const credits = await fetchAccountAICredits(); const prices = await getPrices(); return ( - + + {props.searchParams.status === 'success' ? ( + + + + + + Your payment was successful! We added the credits to your account. + + + ) : null} + @@ -40,6 +72,6 @@ export default async function Page() { - + ); } diff --git a/env.mjs b/env.mjs index 035b46c..78bf11b 100644 --- a/env.mjs +++ b/env.mjs @@ -21,6 +21,7 @@ export const env = createEnv({ SLACK_POSTMAN_WEBHOOK_URL: process.env.SLACK_POSTMAN_WEBHOOK_URL, STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL: process.env.SUPABASE_URL, }, @@ -33,6 +34,7 @@ export const env = createEnv({ SLACK_POSTMAN_WEBHOOK_URL: z.string().min(1), STRIPE_PUBLISHABLE_KEY: z.string().min(1), STRIPE_SECRET_KEY: z.string().min(1), + STRIPE_WEBHOOK_SECRET: z.string().min(1), SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), SUPABASE_URL: z.string().min(1), }, diff --git a/lib/services/stripe/client.ts b/lib/services/stripe/client.ts index fae1f13..558512f 100644 --- a/lib/services/stripe/client.ts +++ b/lib/services/stripe/client.ts @@ -2,6 +2,9 @@ import { env } from '@/env.mjs'; import { createExternalServiceError } from '@/lib/errors'; import { Stripe } from 'stripe'; -export const stripe = new Stripe(env.STRIPE_SECRET_KEY); +export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { + apiVersion: '2023-10-16', + typescript: true, +}); export const StripeError = createExternalServiceError('Stripe'); diff --git a/lib/services/stripe/order.ts b/lib/services/stripe/order.ts new file mode 100644 index 0000000..339054a --- /dev/null +++ b/lib/services/stripe/order.ts @@ -0,0 +1,59 @@ +import type { Stripe } from 'stripe'; + +import { DatabaseError } from '@/lib/errors'; +import { z } from 'zod'; + +import { createSupabaseServiceClient } from '../supabase/service'; + +const checkoutSessionSchema = z.object({ + id: z.string(), + line_items: z.object({ + data: z.array( + z.object({ + price: z.object({ + transform_quantity: z.object({ + divide_by: z.number(), + }), + }), + }), + ), + }), + metadata: z.object({ + accountId: z.string().min(1).transform(Number), + userId: z.string().min(1), + }), +}); + +export const fulfillOrder = async (session: Stripe.Checkout.Session) => { + const validatedSession = checkoutSessionSchema.safeParse(session); + + if (!validatedSession.success) { + throw validatedSession.error; + } + + const supabase = createSupabaseServiceClient(); + + const currentAccountCreditsQuery = await supabase + .from('account') + .select('ai_credit') + .eq('id', validatedSession.data.metadata.accountId) + .single(); + + if (currentAccountCreditsQuery.error) { + throw new DatabaseError(currentAccountCreditsQuery.error); + } + + const updateAccountQuery = await supabase + .from('account') + .update({ + ai_credit: + (currentAccountCreditsQuery.data.ai_credit ?? 0) + + validatedSession.data.line_items.data[0].price.transform_quantity + .divide_by, + }) + .eq('id', validatedSession.data.metadata.accountId); + + if (updateAccountQuery.error) { + throw new DatabaseError(updateAccountQuery.error); + } +}; diff --git a/middleware.ts b/middleware.ts index 3b8d15b..e5a5a18 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,7 +3,13 @@ import type { NextRequest } from 'next/server'; import { getSupabaseAuthSession } from '@/lib/services/supabase/auth'; import { NextResponse } from 'next/server'; -const publicRoutes = ['/', '/auth/callback', '/auth/sign-in', '/sign-in']; +const publicRoutes = [ + '/', + '/auth/callback', + '/auth/sign-in', + '/sign-in', + '/api/webhooks/stripe', +]; export async function middleware(request: NextRequest) { const { pathname } = new URL(request.url);