Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=sb_publishable_your-key
DATABASE_URL=postgresql://postgres:password@db.your-project.supabase.co:6543/postgres
DATABASE_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres
STRIPE_PRICE_ID=
STRIPE_SECRET_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,6 @@ drizzle/meta/

.agents/
.playwright-mcp/
.mcp.json
.mcp.json
.claude/
.cursor/
39 changes: 39 additions & 0 deletions app/api/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { stripe, getOrCreateStripeCustomer } from "@/lib/stripe";
import { createClient } from "@/utils/supabase/server";

export async function POST(request: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

if (!user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}

const { priceId, mode = "subscription" } = await request.json();

if (!priceId) {
return NextResponse.json(
{ error: "Price ID is required" },
{ status: 400 },
);
}

const stripeCustomerId = await getOrCreateStripeCustomer(
user.id,
user.email!,
);

const session = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode: mode as "subscription" | "payment",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${request.nextUrl.origin}/checkout/success`,
cancel_url: `${request.nextUrl.origin}/checkout/cancel`,
});

return NextResponse.json({ url: session.url });
}
90 changes: 90 additions & 0 deletions app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { stripe, syncStripeData } from "@/lib/stripe";
import { db } from "@/lib/db";
import { profiles, purchases } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import Stripe from "stripe";

const allowedEvents: Stripe.Event.Type[] = [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed",
"invoice.paid",
"invoice.payment_failed",
"invoice.payment_succeeded",
];

export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");

if (!signature) {
return NextResponse.json({ error: "No signature" }, { status: 400 });
}

let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

if (!allowedEvents.includes(event.type)) {
return NextResponse.json({ received: true });
}

if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;

if (session.mode === "payment" && session.customer) {
const customerId = session.customer as string;

const profile = await db.query.profiles.findFirst({
where: eq(profiles.stripeCustomerId, customerId),
});

if (profile) {
const lineItems = await stripe.checkout.sessions.listLineItems(
session.id,
{ limit: 1 },
);
const item = lineItems.data[0];
if (item) {
await db.insert(purchases).values({
userId: profile.id,
stripePriceId: item.price?.id ?? "",
stripeSessionId: session.id,
productName: item.description ?? "Product",
amount: session.amount_total ?? 0,
currency: session.currency ?? "cad",
});
}
}

return NextResponse.json({ received: true });
}
}

const { customer: customerId } = event.data.object as {
customer: string;
};

if (typeof customerId !== "string") {
console.error(
`[STRIPE WEBHOOK] No customer ID on event type: ${event.type}`,
);
return NextResponse.json({ received: true });
}

await syncStripeData(customerId);

return NextResponse.json({ received: true });
}
35 changes: 35 additions & 0 deletions app/checkout/cancel/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Link from "next/link";

export default function CheckoutCancelPage() {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-md text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<svg
className="h-8 w-8 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<h1 className="mb-2 text-2xl font-bold">Payment Cancelled</h1>
<p className="mb-8 text-gray-600">
Your payment was not processed. You have not been charged.
</p>
<Link
href="/"
className="inline-block rounded-lg bg-black px-6 py-3 text-sm font-medium text-white hover:bg-gray-800"
>
Go Back
</Link>
</div>
</div>
);
}
29 changes: 29 additions & 0 deletions app/checkout/success/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { syncStripeData } from "@/lib/stripe";
import { db } from "@/lib/db";
import { profiles } from "@/lib/db/schema";
import { eq } from "drizzle-orm";

export default async function CheckoutSuccessPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

if (!user) {
redirect("/login");
}

const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
});

if (!profile?.stripeCustomerId) {
redirect("/");
}

await syncStripeData(profile.stripeCustomerId);

redirect("/");
}
157 changes: 130 additions & 27 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,139 @@
import { createClient } from "@/utils/supabase/server";
import { signout } from "./login/actions";
import { getSubscriptionDetails } from "@/lib/stripe";
import { hasUserPurchased } from "@/lib/purchases";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CheckoutButton } from "@/components/subscribe-button";

const SUBSCRIPTION_PRICE_ID = process.env.STRIPE_PRICE_ID!;
const PRODUCT_PRICE_ID = process.env.STRIPE_PRODUCT_PRICE_ID!;

export default async function Page() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

if (!user) return null;

const [subscription, ownsProduct] = await Promise.all([
getSubscriptionDetails(user.id),
hasUserPurchased(user.id, PRODUCT_PRICE_ID),
]);

return (
<main className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Welcome</CardTitle>
<CardDescription>You are signed in as</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm font-medium">{user.email}</p>
<form>
<Button formAction={signout} variant="outline">
Sign out
</Button>
</form>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Subscription</CardTitle>
<CardDescription>
{subscription
? "Your current plan"
: "Subscribe to unlock premium features"}
</CardDescription>
</CardHeader>
<CardContent>
{subscription ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Plan</span>
<span className="text-sm font-medium">
{subscription.planName}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Price</span>
<span className="text-sm font-medium">
${(subscription.priceAmount / 100).toFixed(2)}/
{subscription.priceInterval}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Status</span>
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-700">
Active
</span>
</div>
</div>
{subscription.paymentMethodBrand && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">
Payment
</span>
<span className="text-sm font-medium capitalize">
{subscription.paymentMethodBrand} ****
{subscription.paymentMethodLast4}
</span>
</div>
)}
{subscription.cancelAtPeriodEnd && (
<p className="text-sm text-amber-600">
Cancels at end of billing period
</p>
)}
</div>
) : (
<CheckoutButton
priceId={SUBSCRIPTION_PRICE_ID}
mode="subscription"
label="Subscribe"
/>
)}
</CardContent>
</Card>

return (
<main className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl">Welcome</CardTitle>
<CardDescription>You are signed in as</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm font-medium">{user?.email}</p>
<form>
<Button formAction={signout} variant="outline">
Sign out
</Button>
</form>
</CardContent>
</Card>
</main>
);
<Card>
<CardHeader>
<CardTitle>Product</CardTitle>
<CardDescription>
{ownsProduct
? "You own this product"
: "One-time purchase"}
</CardDescription>
</CardHeader>
<CardContent>
{ownsProduct ? (
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-700">
Purchased
</span>
</div>
) : (
<CheckoutButton
priceId={PRODUCT_PRICE_ID}
mode="payment"
label="Buy Now"
/>
)}
</CardContent>
</Card>
</div>
</main>
);
}
Loading