Skip to content

Commit

Permalink
feat: add stripe webhook (#76)
Browse files Browse the repository at this point in the history
feat: add  to env

feat: add more params to stripe client

feat: add stripe webhook

feat: update parser
  • Loading branch information
altaywtf authored Dec 16, 2023
1 parent 483a14b commit 717d77e
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 6 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ SLACK_POSTMAN_WEBHOOK_URL=

STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
59 changes: 59 additions & 0 deletions app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -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');
}
40 changes: 36 additions & 4 deletions app/credits/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box mx="auto" style={{ maxWidth: 'var(--container-1)' }}>
<Flex
direction="column"
gap="4"
mx="auto"
style={{ maxWidth: 'var(--container-1)' }}
>
{props.searchParams.status === 'success' ? (
<CalloutRoot color="green">
<CalloutIcon>
<FaCircleCheck />
</CalloutIcon>
<CalloutText>
Your payment was successful! We added the credits to your account.
</CalloutText>
</CalloutRoot>
) : null}

<Card size="3">
<Flex direction="column" gap="4">
<Flex direction="column" gap="2">
Expand Down Expand Up @@ -40,6 +72,6 @@ export default async function Page() {
</Provider>
</Flex>
</Card>
</Box>
</Flex>
);
}
2 changes: 2 additions & 0 deletions env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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),
},
Expand Down
5 changes: 4 additions & 1 deletion lib/services/stripe/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
59 changes: 59 additions & 0 deletions lib/services/stripe/order.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
8 changes: 7 additions & 1 deletion middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit 717d77e

Please sign in to comment.