diff --git a/web/package-lock.json b/web/package-lock.json index 50e3206..832bfd1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "web", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@auth/supabase-adapter": "^1.11.1", "@supabase/supabase-js": "^2.93.3", diff --git a/web/src/app/api/v1/subscription/create-checkout/route.ts b/web/src/app/api/v1/subscription/create-checkout/route.ts new file mode 100644 index 0000000..7c9bac4 --- /dev/null +++ b/web/src/app/api/v1/subscription/create-checkout/route.ts @@ -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 } + ); + } +} diff --git a/web/src/app/api/v1/subscription/portal/route.ts b/web/src/app/api/v1/subscription/portal/route.ts new file mode 100644 index 0000000..f1d2437 --- /dev/null +++ b/web/src/app/api/v1/subscription/portal/route.ts @@ -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 } + ); + } +} diff --git a/web/src/app/api/v1/subscription/webhook/route.ts b/web/src/app/api/v1/subscription/webhook/route.ts new file mode 100644 index 0000000..4789d1a --- /dev/null +++ b/web/src/app/api/v1/subscription/webhook/route.ts @@ -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 } + ); + } +} diff --git a/web/src/app/settings/subscription/page.tsx b/web/src/app/settings/subscription/page.tsx index 96d80ef..9bbd726 100644 --- a/web/src/app/settings/subscription/page.tsx +++ b/web/src/app/settings/subscription/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import Header from '@/components/Header'; interface SubscriptionData { @@ -58,6 +58,7 @@ const PLANS = { export default function SubscriptionPage() { const { data: session, status } = useSession(); const router = useRouter(); + const searchParams = useSearchParams(); const [subscription, setSubscription] = useState({ plan: 'free', status: 'active', @@ -67,6 +68,22 @@ export default function SubscriptionPage() { analyses_limit: 3, }); const [isLoading, setIsLoading] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); + + // Check URL params for success/cancel messages + useEffect(() => { + const success = searchParams.get('success'); + const canceled = searchParams.get('canceled'); + + if (success === 'true') { + setMessage({ type: 'success', text: 'Payment successful! Your subscription has been updated.' }); + // Clear URL params + router.replace('/settings/subscription'); + } else if (canceled === 'true') { + setMessage({ type: 'info', text: 'Checkout canceled. Your subscription was not changed.' }); + router.replace('/settings/subscription'); + } + }, [searchParams, router]); // Redirect to login if not authenticated useEffect(() => { @@ -77,19 +94,20 @@ export default function SubscriptionPage() { const handleManageBilling = async () => { setIsLoading(true); + setMessage(null); try { const res = await fetch('/api/v1/subscription/portal', { method: 'POST', }); - if (res.ok) { - const data = await res.json(); + const data = await res.json(); + if (res.ok && data.url) { window.location.href = data.url; } else { - alert('Billing portal not configured yet. Coming soon!'); + setMessage({ type: 'error', text: data.error || 'Failed to open billing portal' }); } } catch (error) { console.error('Failed to open billing portal:', error); - alert('Billing portal not configured yet. Coming soon!'); + setMessage({ type: 'error', text: 'Failed to connect to billing service' }); } finally { setIsLoading(false); } @@ -97,21 +115,22 @@ export default function SubscriptionPage() { const handleUpgrade = async (plan: string) => { setIsLoading(true); + setMessage(null); try { const res = await fetch('/api/v1/subscription/create-checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plan }), }); - if (res.ok) { - const data = await res.json(); + const data = await res.json(); + if (res.ok && data.url) { window.location.href = data.url; } else { - alert('Checkout not configured yet. Coming soon!'); + setMessage({ type: 'error', text: data.error || 'Failed to start checkout' }); } } catch (error) { console.error('Failed to create checkout session:', error); - alert('Checkout not configured yet. Coming soon!'); + setMessage({ type: 'error', text: 'Failed to connect to payment service' }); } finally { setIsLoading(false); } @@ -141,6 +160,46 @@ export default function SubscriptionPage() {
+ {/* Status Message */} + {message && ( +
+
+ {message.type === 'success' && ( + + + + )} + {message.type === 'error' && ( + + + + )} + {message.type === 'info' && ( + + + + )} + {message.text} +
+ +
+ )} +
diff --git a/web/src/lib/lemonsqueezy.ts b/web/src/lib/lemonsqueezy.ts new file mode 100644 index 0000000..49c28e2 --- /dev/null +++ b/web/src/lib/lemonsqueezy.ts @@ -0,0 +1,168 @@ +// LemonSqueezy API configuration +// Docs: https://docs.lemonsqueezy.com/api + +const LEMONSQUEEZY_API_URL = 'https://api.lemonsqueezy.com/v1'; + +if (!process.env.LEMONSQUEEZY_API_KEY) { + console.warn('LEMONSQUEEZY_API_KEY is not set in environment variables'); +} + +// Helper function for API requests +export async function lemonSqueezyApi( + endpoint: string, + options: RequestInit = {} +): Promise { + const url = `${LEMONSQUEEZY_API_URL}${endpoint}`; + + return fetch(url, { + ...options, + headers: { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + 'Authorization': `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`, + ...options.headers, + }, + }); +} + +// Variant IDs - these are created in your LemonSqueezy dashboard +// A variant is a specific price/plan for a product +export const VARIANT_IDS = { + monthly: process.env.LEMONSQUEEZY_VARIANT_MONTHLY || '', + annual: process.env.LEMONSQUEEZY_VARIANT_ANNUAL || '', +}; + +// Store ID - your LemonSqueezy store +export const STORE_ID = process.env.LEMONSQUEEZY_STORE_ID || ''; + +// Plan configurations +export const PLANS = { + free: { + name: 'Free', + analyses: 3, + features: ['3 analyses per month', 'Basic TinyML detection', 'CSV export'], + }, + monthly: { + name: 'Monthly', + price: 9, + analyses: 50, + variantId: VARIANT_IDS.monthly, + features: ['50 analyses per month', 'Advanced AI models', 'Priority processing', 'Email support'], + }, + annual: { + name: 'Annual', + price: 89, + analyses: -1, // unlimited + variantId: VARIANT_IDS.annual, + features: ['Unlimited analyses', 'All AI models + API', 'White-label reports', 'Priority support', '2 months free'], + }, +}; + +export type PlanType = keyof typeof PLANS; + +// Create a checkout session +export async function createCheckout(params: { + variantId: string; + email: string; + name?: string; + customData?: Record; +}): Promise<{ checkoutUrl: string } | { error: string }> { + const { variantId, email, name, customData } = params; + + if (!variantId) { + return { error: 'Variant ID not configured. Please set LEMONSQUEEZY_VARIANT_MONTHLY and LEMONSQUEEZY_VARIANT_ANNUAL.' }; + } + + if (!STORE_ID) { + return { error: 'Store ID not configured. Please set LEMONSQUEEZY_STORE_ID.' }; + } + + try { + const response = await lemonSqueezyApi('/checkouts', { + method: 'POST', + body: JSON.stringify({ + data: { + type: 'checkouts', + attributes: { + checkout_data: { + email, + name: name || undefined, + custom: customData || {}, + }, + checkout_options: { + dark: false, + embed: false, + logo: true, + }, + product_options: { + redirect_url: `${process.env.NEXTAUTH_URL}/settings/subscription?success=true`, + receipt_link_url: `${process.env.NEXTAUTH_URL}/settings/subscription`, + }, + }, + relationships: { + store: { + data: { + type: 'stores', + id: STORE_ID, + }, + }, + variant: { + data: { + type: 'variants', + id: variantId, + }, + }, + }, + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error('LemonSqueezy checkout error:', errorData); + return { error: errorData.errors?.[0]?.detail || 'Failed to create checkout' }; + } + + const data = await response.json(); + return { checkoutUrl: data.data.attributes.url }; + } catch (error) { + console.error('LemonSqueezy API error:', error); + return { error: 'Failed to connect to payment service' }; + } +} + +// Get customer portal URL +export async function getCustomerPortalUrl(customerId: string): Promise<{ portalUrl: string } | { error: string }> { + try { + const response = await lemonSqueezyApi(`/customers/${customerId}`); + + if (!response.ok) { + const errorData = await response.json(); + return { error: errorData.errors?.[0]?.detail || 'Failed to get customer portal' }; + } + + const data = await response.json(); + const portalUrl = data.data.attributes.urls?.customer_portal; + + if (!portalUrl) { + return { error: 'Customer portal URL not available' }; + } + + return { portalUrl }; + } catch (error) { + console.error('LemonSqueezy API error:', error); + return { error: 'Failed to connect to billing service' }; + } +} + +// Verify webhook signature +export function verifyWebhookSignature( + payload: string, + signature: string, + secret: string +): boolean { + const crypto = require('crypto'); + const hmac = crypto.createHmac('sha256', secret); + const digest = hmac.update(payload).digest('hex'); + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest)); +}