Skip to content
Open
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
4 changes: 2 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 72 additions & 0 deletions web/src/app/api/v1/subscription/create-checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { createCheckout, PLANS, PlanType } from '@/lib/lemonsqueezy';

export async function POST(request: NextRequest) {
try {
// Check authentication
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}

const body = await request.json();
const { plan } = body as { plan: PlanType };

// Validate plan
if (!plan || !['monthly', 'annual'].includes(plan)) {
return NextResponse.json(
{ error: 'Invalid plan. Must be "monthly" or "annual"' },
{ status: 400 }
);
}

const selectedPlan = PLANS[plan];
if (!('variantId' in selectedPlan)) {
return NextResponse.json(
{ error: 'Plan does not have a price configured' },
{ status: 400 }
);
}

// Create checkout session with LemonSqueezy
const result = await createCheckout({
variantId: selectedPlan.variantId,
email: session.user.email,
name: session.user.name || undefined,
customData: {
user_email: session.user.email,
plan: plan,
},
});

if ('error' in result) {
return NextResponse.json(
{ error: result.error },
{ status: 500 }
);
}

return NextResponse.json({
url: result.checkoutUrl,
});
} catch (error) {
console.error('Checkout error:', error);

if (error instanceof Error) {
return NextResponse.json(
{ error: error.message },
{ status: 500 }
);
}

return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
);
}
}
68 changes: 68 additions & 0 deletions web/src/app/api/v1/subscription/portal/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { lemonSqueezyApi, STORE_ID } from '@/lib/lemonsqueezy';

export async function POST(request: NextRequest) {
try {
// Check authentication
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}

// Find customer by email
const customersResponse = await lemonSqueezyApi(
`/customers?filter[store_id]=${STORE_ID}&filter[email]=${encodeURIComponent(session.user.email)}`
);

if (!customersResponse.ok) {
const errorData = await customersResponse.json();
console.error('LemonSqueezy customers error:', errorData);
return NextResponse.json(
{ error: 'Failed to find customer record' },
{ status: 500 }
);
}

const customersData = await customersResponse.json();

if (!customersData.data || customersData.data.length === 0) {
return NextResponse.json(
{ error: 'No billing account found. Please subscribe to a plan first.' },
{ status: 404 }
);
}

const customer = customersData.data[0];
const portalUrl = customer.attributes.urls?.customer_portal;

if (!portalUrl) {
return NextResponse.json(
{ error: 'Customer portal not available' },
{ status: 404 }
);
}

return NextResponse.json({
url: portalUrl,
});
} catch (error) {
console.error('Portal error:', error);

if (error instanceof Error) {
return NextResponse.json(
{ error: error.message },
{ status: 500 }
);
}

return NextResponse.json(
{ error: 'Failed to access billing portal' },
{ status: 500 }
);
}
}
212 changes: 212 additions & 0 deletions web/src/app/api/v1/subscription/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

// Disable body parsing, we need the raw body for webhook verification
export const dynamic = 'force-dynamic';

// LemonSqueezy webhook event types
type LemonSqueezyEvent =
| 'order_created'
| 'order_refunded'
| 'subscription_created'
| 'subscription_updated'
| 'subscription_cancelled'
| 'subscription_resumed'
| 'subscription_expired'
| 'subscription_paused'
| 'subscription_unpaused'
| 'subscription_payment_success'
| 'subscription_payment_failed'
| 'subscription_payment_recovered';

interface WebhookPayload {
meta: {
event_name: LemonSqueezyEvent;
custom_data?: {
user_email?: string;
plan?: string;
};
};
data: {
id: string;
type: string;
attributes: {
status?: string;
user_email?: string;
customer_id?: number;
variant_id?: number;
product_id?: number;
order_id?: number;
renews_at?: string;
ends_at?: string;
[key: string]: unknown;
};
};
}

function verifySignature(payload: string, signature: string, secret: string): boolean {
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(payload).digest('hex');

try {
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
} catch {
return false;
}
}

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

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

const webhookSecret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
if (!webhookSecret) {
console.error('LEMONSQUEEZY_WEBHOOK_SECRET is not set');
return NextResponse.json(
{ error: 'Webhook secret not configured' },
{ status: 500 }
);
}

// Verify signature
if (!verifySignature(body, signature, webhookSecret)) {
console.error('Webhook signature verification failed');
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}

let payload: WebhookPayload;
try {
payload = JSON.parse(body);
} catch {
return NextResponse.json(
{ error: 'Invalid JSON payload' },
{ status: 400 }
);
}

const eventName = payload.meta.event_name;
const customData = payload.meta.custom_data;

// Handle the event
try {
switch (eventName) {
case 'order_created': {
console.log('Order created:', {
orderId: payload.data.id,
userEmail: customData?.user_email || payload.data.attributes.user_email,
plan: customData?.plan,
});

// TODO: Grant initial access to the user
// await grantAccess(customData?.user_email, customData?.plan);
break;
}

case 'subscription_created': {
console.log('Subscription created:', {
subscriptionId: payload.data.id,
userEmail: customData?.user_email,
plan: customData?.plan,
status: payload.data.attributes.status,
});

// TODO: Update user's subscription in your database
// await updateUserSubscription(customData?.user_email, {
// plan: customData?.plan,
// subscriptionId: payload.data.id,
// status: 'active',
// });
break;
}

case 'subscription_updated': {
console.log('Subscription updated:', {
subscriptionId: payload.data.id,
status: payload.data.attributes.status,
renewsAt: payload.data.attributes.renews_at,
});

// TODO: Update subscription status
// await updateSubscriptionStatus(payload.data.id, payload.data.attributes.status);
break;
}

case 'subscription_cancelled':
case 'subscription_expired': {
console.log('Subscription ended:', {
subscriptionId: payload.data.id,
event: eventName,
endsAt: payload.data.attributes.ends_at,
});

// TODO: Handle subscription cancellation
// await handleSubscriptionEnd(payload.data.id);
break;
}

case 'subscription_payment_success': {
console.log('Payment succeeded:', {
subscriptionId: payload.data.id,
customerId: payload.data.attributes.customer_id,
});

// TODO: Record successful payment, extend access
// await recordPayment(payload.data.id, 'success');
break;
}

case 'subscription_payment_failed': {
console.log('Payment failed:', {
subscriptionId: payload.data.id,
customerId: payload.data.attributes.customer_id,
});

// TODO: Handle failed payment (send notification, update status)
// await handleFailedPayment(payload.data.id);
break;
}

case 'subscription_paused': {
console.log('Subscription paused:', {
subscriptionId: payload.data.id,
});

// TODO: Pause user access
// await pauseSubscription(payload.data.id);
break;
}

case 'subscription_resumed':
case 'subscription_unpaused': {
console.log('Subscription resumed:', {
subscriptionId: payload.data.id,
});

// TODO: Resume user access
// await resumeSubscription(payload.data.id);
break;
}

default:
console.log(`Unhandled event type: ${eventName}`);
}

return NextResponse.json({ received: true });
} catch (error) {
console.error('Error processing webhook:', error);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 500 }
);
}
}
Loading