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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,17 @@ DATABASE_URL=postgresql://...
BLOB_READ_WRITE_TOKEN=...

# Stack Auth
STACK_PROJECT_ID=...
STACK_PUBLISHED_CLIENT_KEY=...
NEXT_PUBLIC_STACK_PROJECT_ID=...
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=...
STACK_SECRET_SERVER_KEY=...

# Stripe Billing
STRIPE_SECRET_KEY=...
STRIPE_WEBHOOK_SECRET=...
STRIPE_PRICE_ID_PRO=...

# Optional app URL override (used for Stripe redirects)
NEXT_PUBLIC_APP_URL=http://localhost:3000
```

4. **Run the development server**
Expand Down
79 changes: 79 additions & 0 deletions app/api/payments/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { getAppBaseUrl, getStripeServerClient, stripePriceByPlan } from '@/lib/stripe';
import {
getUserSubscription,
markOnboardingStepCompleted,
upsertUserSubscription,
} from '@/lib/queries';

export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const stripe = getStripeServerClient();
const plan = ((await request.json().catch(() => ({}))) as { plan?: string }).plan ?? 'pro';
const normalizedPlan = plan === 'pro' ? 'pro' : 'pro';
const priceId = stripePriceByPlan[normalizedPlan];

if (!stripe || !priceId) {
return NextResponse.json(
{ error: 'Stripe checkout is not configured.' },
{ status: 500 }
);
}

const existingSubscription = await getUserSubscription(user.id);
let stripeCustomerId = existingSubscription?.stripe_customer_id ?? null;

if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.primaryEmail || undefined,
name: user.displayName || undefined,
metadata: { user_id: user.id },
});
stripeCustomerId = customer.id;
}

const baseUrl = getAppBaseUrl(request.nextUrl.origin);

const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: stripeCustomerId,
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: user.id,
metadata: {
user_id: user.id,
plan: normalizedPlan,
},
allow_promotion_codes: true,
success_url: `${baseUrl}/dashboard/billing?checkout=success`,
cancel_url: `${baseUrl}/dashboard/billing?checkout=cancelled`,
});

await upsertUserSubscription(user.id, {
stripe_customer_id: stripeCustomerId,
plan: existingSubscription?.plan ?? 'free',
status: existingSubscription?.status ?? 'inactive',
stripe_subscription_id: existingSubscription?.stripe_subscription_id ?? null,
current_period_end: existingSubscription?.current_period_end ?? null,
cancel_at_period_end: existingSubscription?.cancel_at_period_end ?? false,
});

if (!checkoutSession.url) {
return NextResponse.json(
{ error: 'Unable to create checkout URL.' },
{ status: 500 }
);
}
await markOnboardingStepCompleted(user.id, 'trial_started');

return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Error creating checkout session:', error);
return NextResponse.json({ error: 'Failed to start checkout' }, { status: 500 });
}
}
53 changes: 53 additions & 0 deletions app/api/payments/portal/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { getAppBaseUrl, getStripeServerClient } from '@/lib/stripe';
import { getUserSubscription, upsertUserSubscription } from '@/lib/queries';

export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const stripe = getStripeServerClient();
if (!stripe) {
return NextResponse.json(
{ error: 'Stripe billing portal is not configured.' },
{ status: 500 }
);
}

const existingSubscription = await getUserSubscription(user.id);
let customerId = existingSubscription?.stripe_customer_id ?? null;

if (!customerId) {
const customer = await stripe.customers.create({
email: user.primaryEmail || undefined,
name: user.displayName || undefined,
metadata: { user_id: user.id },
});
customerId = customer.id;

await upsertUserSubscription(user.id, {
stripe_customer_id: customerId,
status: existingSubscription?.status ?? 'inactive',
plan: existingSubscription?.plan ?? 'free',
stripe_subscription_id: existingSubscription?.stripe_subscription_id ?? null,
current_period_end: existingSubscription?.current_period_end ?? null,
cancel_at_period_end: existingSubscription?.cancel_at_period_end ?? false,
});
}

const returnUrl = `${getAppBaseUrl(request.nextUrl.origin)}/dashboard/billing`;
const portal = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});

return NextResponse.json({ url: portal.url });
} catch (error) {
console.error('Error creating billing portal session:', error);
return NextResponse.json({ error: 'Failed to open billing portal' }, { status: 500 });
}
}
121 changes: 121 additions & 0 deletions app/api/payments/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
import { getStripeServerClient, stripePriceByPlan } from '@/lib/stripe';
import {
getUserSubscriptionByCustomerId,
updateUserSubscriptionByCustomerId,
upsertUserSubscription,
} from '@/lib/queries';

export const runtime = 'nodejs';

function mapPriceIdToPlan(priceId: string | null | undefined) {
if (!priceId) return 'free';
if (priceId === stripePriceByPlan.pro) return 'pro';
return 'free';
}

function timestampToIso(value: number | null | undefined) {
if (!value) return null;
return new Date(value * 1000).toISOString();
}

export async function POST(request: NextRequest) {
try {
const stripe = getStripeServerClient();
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

if (!stripe || !webhookSecret) {
return NextResponse.json(
{ error: 'Stripe webhook is not configured.' },
{ status: 500 }
);
}

const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing Stripe signature' }, { status: 400 });
}

const payload = await request.text();
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);

if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.user_id ?? session.client_reference_id ?? null;
const customerId = typeof session.customer === 'string' ? session.customer : null;
const subscriptionId =
typeof session.subscription === 'string' ? session.subscription : null;
const plan = session.metadata?.plan ?? 'pro';

if (userId && customerId) {
await upsertUserSubscription(userId, {
stripe_customer_id: customerId,
stripe_subscription_id: subscriptionId,
status: 'active',
plan,
cancel_at_period_end: false,
});
}
}

if (
event.type === 'customer.subscription.created' ||
event.type === 'customer.subscription.updated' ||
event.type === 'customer.subscription.deleted'
) {
const subscription = event.data.object as Stripe.Subscription;
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;

const priceId = subscription.items.data[0]?.price?.id;
const plan = mapPriceIdToPlan(priceId);
const currentPeriodEnd = timestampToIso(subscription.current_period_end);
const status =
event.type === 'customer.subscription.deleted'
? 'canceled'
: subscription.status;

const updated = await updateUserSubscriptionByCustomerId(customerId, {
stripe_subscription_id: subscription.id,
status,
plan,
current_period_end: currentPeriodEnd,
cancel_at_period_end: subscription.cancel_at_period_end,
});

if (!updated) {
const fromMetadata = subscription.metadata?.user_id;
if (fromMetadata) {
await upsertUserSubscription(fromMetadata, {
stripe_customer_id: customerId,
stripe_subscription_id: subscription.id,
status,
plan,
current_period_end: currentPeriodEnd,
cancel_at_period_end: subscription.cancel_at_period_end,
});
} else {
const existingByCustomer = await getUserSubscriptionByCustomerId(customerId);
if (existingByCustomer) {
await upsertUserSubscription(existingByCustomer.user_id, {
stripe_customer_id: customerId,
stripe_subscription_id: subscription.id,
status,
plan,
current_period_end: currentPeriodEnd,
cancel_at_period_end: subscription.cancel_at_period_end,
});
}
}
}
}

return NextResponse.json({ received: true });
} catch (error) {
console.error('Stripe webhook error:', error);
return NextResponse.json({ error: 'Webhook handling failed' }, { status: 400 });
}
}
69 changes: 69 additions & 0 deletions app/api/profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import {
getUserProfile,
markOnboardingStepCompleted,
upsertUserProfile,
} from '@/lib/queries';

function cleanNullableString(value: unknown, maxLength = 255) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.slice(0, maxLength);
}

export async function GET() {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const profile = await getUserProfile(user.id);
if (profile) {
return NextResponse.json(profile);
}

const created = await upsertUserProfile(user.id, {
display_name: user.displayName ?? null,
billing_email: user.primaryEmail ?? null,
timezone: 'UTC',
});

return NextResponse.json(created);
} catch (error) {
console.error('Error fetching profile:', error);
return NextResponse.json({ error: 'Failed to fetch profile' }, { status: 500 });
}
}

export async function PUT(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const body = (await request.json()) as Record<string, unknown>;

const timezone =
typeof body.timezone === 'string' && body.timezone.trim().length > 0
? body.timezone.trim().slice(0, 100)
: 'UTC';

const profile = await upsertUserProfile(user.id, {
display_name: cleanNullableString(body.display_name, 255),
company_name: cleanNullableString(body.company_name, 255),
job_title: cleanNullableString(body.job_title, 255),
billing_email: cleanNullableString(body.billing_email, 255),
timezone,
});
await markOnboardingStepCompleted(user.id, 'profile_completed');

return NextResponse.json(profile);
} catch (error) {
console.error('Error updating profile:', error);
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
}
}
Loading