Skip to content

Commit

Permalink
lesog
Browse files Browse the repository at this point in the history
  • Loading branch information
JUNIORCO committed Sep 6, 2024
1 parent b0a3b07 commit 5182e24
Show file tree
Hide file tree
Showing 45 changed files with 883 additions and 99 deletions.
25 changes: 17 additions & 8 deletions .env.template
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
CLERK_SECRET_KEY=""
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
# client
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_"
NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in"
NEXT_PUBLIC_CLERK_SIGN_UP_URL="/sign-up"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_"

OPENAI_API_KEY=""
FIRECRAWL_API_KEY=""
RESEND_API_KEY=""
TRIGGER_SECRET_KEY=""
POSTGRES_PRISMA_URL=""
POSTGRES_URL_NON_POOLING=""
# server
CLERK_SECRET_KEY="sk_"
CLERK_WEBHOOK_SECRET="whsec_"
FIRECRAWL_API_KEY="fc-"
OPENAI_API_KEY="sk-"
POSTGRES_PRISMA_URL="postgresql://"
POSTGRES_URL_NON_POOLING="postgresql://"
RESEND_API_KEY="re_"
STRIPE_METER_ID="mtr_"
STRIPE_PRICE_ID="price_"
STRIPE_SECRET_KEY="sk_test_"
STRIPE_WEBHOOK_SECRET="whsec_"
TRIGGER_SECRET_KEY="tr_"
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
### Pyngme

`bun run dev`

`bun run trigger`

`lt --port 3000 --subdomain big-orange-machine`

`stripe listen --forward-to localhost:4242/stripe_webhooks`

Webhooks are at `/api/clerk` and `/api/stripe`
10 changes: 9 additions & 1 deletion app/actions/create-pyng.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export async function createPyng(data: IFormInput) {
try {
console.log("Creating pyng...", data);

if (!data.clerkUserId) {
throw new Error("clerkUserId is required");
}
if (!data.stripeCustomerId) {
throw new Error("stripeCustomerId is required");
}

const openai = new OpenAI();
const completion = await openai.chat.completions.create({
messages: [
Expand All @@ -35,12 +42,13 @@ For example, a user can set up "email <> when <a new blog post is release> for <
const pyng = await prisma.pyng.create({
data: {
name,
userId: data.userId,
email: data.email,
every: data.every,
timezone: data.timezone,
url: data.for,
condition: data.when,
clerkUserId: data.clerkUserId,
stripeCustomerId: data.stripeCustomerId,
},
});

Expand Down
7 changes: 7 additions & 0 deletions app/actions/fetch-setup-intent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use server";

import stripe from "@/stripe";

export async function fetchSetupIntent(stripeSetupIntentId: string) {
return await stripe.setupIntents.retrieve(stripeSetupIntentId);
}
27 changes: 27 additions & 0 deletions app/actions/fetch-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use server";

import stripe from "@/stripe";

export async function fetchUsage(
stripeCustomerId: string,
stripeSubscriptionId: string,
) {
const subscription =
await stripe.subscriptions.retrieve(stripeSubscriptionId);

const meterEventSummaries = await stripe.billing.meters.listEventSummaries(
process.env.STRIPE_METER_ID as string,
{
customer: stripeCustomerId,
start_time: subscription.current_period_start,
end_time: subscription.current_period_end,
limit: 100,
},
);

const totalUsage = meterEventSummaries.data.reduce((acc, curr) => {
return acc + curr.aggregated_value;
}, 0);

return totalUsage;
}
54 changes: 54 additions & 0 deletions app/api/clerk/on-user-create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import stripe from "@/stripe";
import type { UserJSON } from "@clerk/nextjs/server";
import { clerkClient } from "@clerk/nextjs/server";
import type Stripe from "stripe";

export default async function onUserCreate(data: UserJSON) {
const customersWithEmail = await stripe.customers.search({
query: `email:\'${data.email_addresses[0].email_address}\'`,
});
console.log("Found customers with email: ", customersWithEmail.data.length);

if (customersWithEmail.data.length > 0) {
console.error("Customer already exists: ", customersWithEmail.data[0].id);
return;
}

const customer = await stripe.customers.create({
name: `${data.first_name} ${data.last_name}`,
email: data.email_addresses[0].email_address,
metadata: {
clerkUserId: data.id,
},
});

console.log("Created customer: ", customer);

const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [
{
price: process.env.STRIPE_PRICE_ID as string,
},
],
expand: ["pending_setup_intent"],
});

console.log("Created subscription: ", subscription.id);
const setupIntentId = (
subscription.pending_setup_intent as Stripe.SetupIntent
).id;

console.log("Setup intent id: ", setupIntentId);

const clerk = clerkClient();
const updatedUser = await clerk.users.updateUserMetadata(data.id, {
publicMetadata: {
stripeCustomerId: customer.id,
stripeSetupIntentId: setupIntentId,
stripeSubscriptionId: subscription.id,
},
});

console.log("Updated user: ", updatedUser);
}
29 changes: 29 additions & 0 deletions app/api/clerk/on-user-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import stripe from "@/stripe";

export default async function onUserDelete(userId: string | undefined) {
if (!userId) {
console.error("User ID is undefined, skipping user deletion");
return;
}

console.log("Searching for customer on stripe with clerkUserId: ", userId);
const customer = await stripe.customers.search({
query: `metadata['clerkUserId']:\'${userId}\'`,
});
console.log("Customer: ", customer);

if (customer.data.length === 0) {
console.error("Customer not found on stripe, skipping deletion");
return;
}

const customerId = customer.data[0].id;
console.log("Stripe customer ID: ", customerId);

const res = await stripe.customers.del(customerId);
if (res.deleted) {
console.log("Deleted customer: ", customerId);
} else {
console.error("Failed to delete Stripe customer: ", customerId);
}
}
47 changes: 47 additions & 0 deletions app/api/clerk/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { WebhookEvent } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { Webhook } from "svix";
import onUserCreate from "./on-user-create";
import onUserDelete from "./on-user-delete";

async function validateRequest(request: Request) {
const payloadString = await request.text();
const headerPayload = headers();
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET || "";

const svixHeaders = {
"svix-id": headerPayload.get("svix-id") as string,
"svix-timestamp": headerPayload.get("svix-timestamp") as string,
"svix-signature": headerPayload.get("svix-signature") as string,
};
const wh = new Webhook(webhookSecret);
return wh.verify(payloadString, svixHeaders) as WebhookEvent;
}

export async function POST(request: Request) {
try {
console.log("Inside Clerk Webhook");
const payload = await validateRequest(request);
console.log("Payload type: ", payload.type);

switch (payload.type) {
case "user.created": {
await onUserCreate(payload.data);
break;
}
case "user.deleted": {
const userId = payload.data.id;
await onUserDelete(userId);
break;
}
}

revalidatePath("/");

return Response.json({ message: "Received" });
} catch (e) {
console.error(e);
return Response.error();
}
}
67 changes: 67 additions & 0 deletions app/api/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { clerkClient } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);

async function validateRequest(request: Request) {
const payloadString = await request.text();
const headerPayload = headers();
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || "";

const signature = headerPayload.get("stripe-signature") as string;

try {
const event = stripe.webhooks.constructEvent(
payloadString,
signature,
webhookSecret,
);
return event;
} catch (err) {
console.error(err);
throw new Error(`Webhook Error: ${err}`);
}
}

export async function POST(request: Request) {
try {
console.log("Inside Stripe Webhook");
const event = await validateRequest(request);
console.log("Event type: ", event.type);

switch (event.type) {
case "setup_intent.succeeded": {
console.log("Setup intent succeeded");
const setupIntent = event.data.object as Stripe.SetupIntent;
const customerId = setupIntent.customer as string;

const customer = await stripe.customers.retrieve(customerId);
if (customer.deleted) {
throw new Error("Customer has been deleted.");
}

const clerkUserId = customer.metadata.clerkUserId as string | undefined;
if (!clerkUserId) {
throw new Error("Clerk user ID not found on customer.");
}

const clerk = clerkClient();
await clerk.users.updateUserMetadata(clerkUserId, {
publicMetadata: {
stripeSetupSucceeded: true,
},
});
break;
}
}

revalidatePath("/");

return Response.json({ received: true });
} catch (e) {
console.error(e);
return new Response("Webhook Error", { status: 400 });
}
}
64 changes: 52 additions & 12 deletions app/components/auth.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,70 @@
"use client";

import Routes from "@/routes";
import { UserButton, useClerk } from "@clerk/nextjs";
import { useClerk, useUser } from "@clerk/nextjs";
import type { UserResource } from "@clerk/types";
import Link from "next/link";
import { useState } from "react";
import { LoaderIcon } from "react-hot-toast";

const UserProfile = () => {
const UserProfile = ({ user }: { user: UserResource }) => {
const [loading, setLoading] = useState(false);
const clerk = useClerk();

return clerk.loaded ? (
<UserButton />
) : (
<div className="skeleton w-7 h-7 rounded-full" />
const showProfile = user?.imageUrl;

const handleSignOut = () => {
setLoading(true);
clerk.signOut(() => {
setLoading(false);
});
};

return (
<div className="dropdown dropdown-bottom dropdown-end">
<div tabIndex={0} role="button" className="avatar">
<div className="w-10 rounded-full">
<img src={clerk.user?.imageUrl} alt="User avatar" />
</div>
</div>
<ul
tabIndex={0}
className="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
>
<li onClick={() => clerk.openUserProfile()}>
<p>Profile</p>
</li>
<li>
<p>Billing</p>
</li>
<li onClick={handleSignOut}>
<p>Sign Out {loading ? <LoaderIcon /> : null}</p>
</li>
</ul>
</div>
);
};

type AuthProps = {
isSignedIn: boolean;
};
export default function Auth({ isSignedIn }: AuthProps) {
return isSignedIn ? (
<UserProfile />
const AuthLoaded = () => {
const { user } = useUser();

return user ? (
<UserProfile user={user} />
) : (
<Link href={Routes.signUp}>
<button type="button" className="btn btn-outline">
Sign Up
</button>
</Link>
);
};

export default function Auth() {
const { isLoaded } = useUser();

return isLoaded ? (
<AuthLoaded />
) : (
<div className="skeleton w-10 h-10 rounded-full" />
);
}
Loading

0 comments on commit 5182e24

Please sign in to comment.