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
9 changes: 8 additions & 1 deletion web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,12 @@ OLLAMA_EMBED_MODEL=nomic-embed-text
# Get this from https://perplexity.ai/
PERPLEXITY_API_KEY=

# Stripe (payments - optional)
# Stripe (payments)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRODUCT_PRO=prod_Ta3YZOPq86bM2J
STRIPE_PRODUCT_ELITE=prod_Ta3aXZCqBcoFnI

# Supabase Service Role (for webhook updates - server-side only)
SUPABASE_SERVICE_ROLE_KEY=
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@sentry/nextjs": "^10.29.0",
"@serwist/next": "^9.2.3",
"@stripe/stripe-js": "^6.1.0",
"stripe": "^17.7.0",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.86.0",
"@tanstack/react-query": "^5.90.3",
Expand Down
90 changes: 90 additions & 0 deletions web/src/app/api/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@/lib/supabase/server';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-04-30.basil',
});

// Use product IDs -- prices are looked up automatically
const PRODUCT_MAP: Record<string, string> = {
pro: process.env.STRIPE_PRODUCT_PRO || 'prod_Ta3YZOPq86bM2J',
elite: process.env.STRIPE_PRODUCT_ELITE || 'prod_Ta3aXZCqBcoFnI',
};

async function getPriceForProduct(productId: string): Promise<string> {
const prices = await stripe.prices.list({
product: productId,
active: true,
type: 'recurring',
limit: 1,
});
if (!prices.data.length) {
throw new Error(`No active recurring price found for product ${productId}`);
}
return prices.data[0].id;
}

export async function POST(req: NextRequest) {
try {
const { tier, user_id, user_email } = await req.json();

if (!tier || !PRODUCT_MAP[tier]) {
return NextResponse.json({ error: 'Invalid tier' }, { status: 400 });
}

if (!user_id || !user_email) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 });
}

const supabase = await createClient();

// Check if user already has a Stripe customer ID
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id')
.eq('id', user_id)
.single();

let customerId = profile?.stripe_customer_id;

// Create Stripe customer if none exists
if (!customerId) {
const customer = await stripe.customers.create({
email: user_email,
metadata: { supabase_user_id: user_id },
});
customerId = customer.id;

await supabase
.from('profiles')
.update({ stripe_customer_id: customerId })
.eq('id', user_id);
}

// Look up price from product ID
const priceId = await getPriceForProduct(PRODUCT_MAP[tier]);

// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${req.nextUrl.origin}/app?upgraded=${tier}`,
cancel_url: `${req.nextUrl.origin}/pricing`,
metadata: {
supabase_user_id: user_id,
tier,
},
});

return NextResponse.json({ url: session.url });
} catch (error) {
console.error('Checkout error:', error);
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
);
}
}
123 changes: 123 additions & 0 deletions web/src/app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient as createSupabaseAdmin } from '@supabase/supabase-js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-04-30.basil',
});

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

// Use service role for webhook (no user session available)
function getAdminClient() {
return createSupabaseAdmin(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}

const PRODUCT_TO_TIER: Record<string, string> = {
[process.env.STRIPE_PRODUCT_PRO || 'prod_Ta3YZOPq86bM2J']: 'pro',
[process.env.STRIPE_PRODUCT_ELITE || 'prod_Ta3aXZCqBcoFnI']: 'elite',
};

async function updateSubscription(
customerId: string,
tier: string,
status: string,
subscriptionId?: string,
endsAt?: string,
) {
const supabase = getAdminClient();

const updates: Record<string, unknown> = {
subscription_tier: tier,
subscription_status: status,
};

if (subscriptionId) updates.stripe_subscription_id = subscriptionId;
if (status === 'active') updates.subscription_starts_at = new Date().toISOString();
if (endsAt) updates.subscription_ends_at = endsAt;

const { error } = await supabase
.from('profiles')
.update(updates)
.eq('stripe_customer_id', customerId);

if (error) {
console.error('Failed to update subscription:', error);
throw error;
}

console.log(`Subscription updated: customer=${customerId} tier=${tier} status=${status}`);
}

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

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

let event: Stripe.Event;

try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}

try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const customerId = session.customer as string;
const tier = session.metadata?.tier || 'pro';
const subscriptionId = session.subscription as string;

await updateSubscription(customerId, tier, 'active', subscriptionId);
break;
}

case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
const productId = sub.items.data[0]?.price.product as string;
const tier = PRODUCT_TO_TIER[productId] || 'pro';
const status = sub.status === 'active' ? 'active' : 'past_due';

await updateSubscription(customerId, tier, status, sub.id);
break;
}

case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
const endsAt = new Date(sub.current_period_end * 1000).toISOString();

await updateSubscription(customerId, 'free', 'canceled', sub.id, endsAt);
break;
}

case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;

await updateSubscription(customerId, 'pro', 'past_due');
console.warn(`Payment failed for customer ${customerId}`);
break;
}

default:
// Ignore other event types
break;
}

return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook handler error:', error);
return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 });
}
}
2 changes: 1 addition & 1 deletion web/src/app/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default function PricingPage() {

try {
// Call backend to create checkout session
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/checkout/create-session`, {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down
Loading