From 606693863a1ca57ff5f742c466a2033920f47436 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 03:45:35 +0000 Subject: [PATCH 1/2] feat(payments): add Stripe integration for subscriptions - Add stripe package dependency - Create /lib/stripe.ts with Stripe configuration and plan definitions - Create /api/v1/subscription/create-checkout endpoint for checkout sessions - Create /api/v1/subscription/portal endpoint for billing management - Create /api/v1/subscription/webhook endpoint for Stripe events - Update subscription page with success/error message handling - Add URL parameter handling for checkout success/cancel redirects Required environment variables: - STRIPE_SECRET_KEY: Your Stripe secret key - STRIPE_PRICE_MONTHLY: Price ID for monthly plan - STRIPE_PRICE_ANNUAL: Price ID for annual plan - STRIPE_WEBHOOK_SECRET: Webhook signing secret https://claude.ai/code/session_011NWj4qHMpnJqseDYB5et8R --- web/package-lock.json | 24 +++- web/package.json | 3 +- .../v1/subscription/create-checkout/route.ts | 108 +++++++++++++++ .../app/api/v1/subscription/portal/route.ts | 56 ++++++++ .../app/api/v1/subscription/webhook/route.ts | 125 ++++++++++++++++++ web/src/app/settings/subscription/page.tsx | 77 +++++++++-- web/src/lib/stripe.ts | 42 ++++++ 7 files changed, 422 insertions(+), 13 deletions(-) create mode 100644 web/src/app/api/v1/subscription/create-checkout/route.ts create mode 100644 web/src/app/api/v1/subscription/portal/route.ts create mode 100644 web/src/app/api/v1/subscription/webhook/route.ts create mode 100644 web/src/lib/stripe.ts diff --git a/web/package-lock.json b/web/package-lock.json index 50e3206..e95dc2b 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", @@ -15,7 +15,8 @@ "next": "16.1.6", "next-auth": "^4.24.13", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "stripe": "^20.3.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -6545,6 +6546,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.0.tgz", + "integrity": "sha512-DYzcmV1MfYhycr1GwjCjeQVYk9Gu8dpxyTlu7qeDCsuguug7oUTxPsUQuZeSf/OPzK7pofqobvOKVqAwlpgf/Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", diff --git a/web/package.json b/web/package.json index 92dac0c..97f3b25 100644 --- a/web/package.json +++ b/web/package.json @@ -16,7 +16,8 @@ "next": "16.1.6", "next-auth": "^4.24.13", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "stripe": "^20.3.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", 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..0e0adee --- /dev/null +++ b/web/src/app/api/v1/subscription/create-checkout/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { stripe, PLANS, PlanType } from '@/lib/stripe'; + +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 (!('stripePriceId' in selectedPlan)) { + return NextResponse.json( + { error: 'Plan does not have a price configured' }, + { status: 400 } + ); + } + + // Check if price ID is configured + if (selectedPlan.stripePriceId.includes('placeholder')) { + return NextResponse.json( + { error: 'Stripe price ID not configured. Please set STRIPE_PRICE_MONTHLY and STRIPE_PRICE_ANNUAL environment variables.' }, + { status: 500 } + ); + } + + // Get or create Stripe customer + const customers = await stripe.customers.list({ + email: session.user.email, + limit: 1, + }); + + let customerId: string; + if (customers.data.length > 0) { + customerId = customers.data[0].id; + } else { + const customer = await stripe.customers.create({ + email: session.user.email, + name: session.user.name || undefined, + metadata: { + userId: session.user.email, + }, + }); + customerId = customer.id; + } + + // Create checkout session + const checkoutSession = await stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', + payment_method_types: ['card'], + line_items: [ + { + price: selectedPlan.stripePriceId, + quantity: 1, + }, + ], + success_url: `${process.env.NEXTAUTH_URL}/settings/subscription?success=true&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${process.env.NEXTAUTH_URL}/settings/subscription?canceled=true`, + metadata: { + userId: session.user.email, + plan: plan, + }, + subscription_data: { + metadata: { + userId: session.user.email, + plan: plan, + }, + }, + }); + + return NextResponse.json({ + url: checkoutSession.url, + sessionId: checkoutSession.id, + }); + } catch (error) { + console.error('Stripe 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..6829928 --- /dev/null +++ b/web/src/app/api/v1/subscription/portal/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { stripe } from '@/lib/stripe'; + +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 customers = await stripe.customers.list({ + email: session.user.email, + limit: 1, + }); + + if (customers.data.length === 0) { + return NextResponse.json( + { error: 'No billing account found. Please subscribe to a plan first.' }, + { status: 404 } + ); + } + + const customerId = customers.data[0].id; + + // Create billing portal session + const portalSession = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${process.env.NEXTAUTH_URL}/settings/subscription`, + }); + + return NextResponse.json({ + url: portalSession.url, + }); + } catch (error) { + console.error('Stripe portal error:', error); + + if (error instanceof Error) { + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'Failed to create billing portal session' }, + { 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..aa53576 --- /dev/null +++ b/web/src/app/api/v1/subscription/webhook/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { stripe } from '@/lib/stripe'; +import Stripe from 'stripe'; + +// Disable body parsing, we need the raw body for webhook verification +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + const body = await request.text(); + const signature = request.headers.get('stripe-signature'); + + if (!signature) { + return NextResponse.json( + { error: 'Missing stripe-signature header' }, + { status: 400 } + ); + } + + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + if (!webhookSecret) { + console.error('STRIPE_WEBHOOK_SECRET is not set'); + return NextResponse.json( + { error: 'Webhook secret not configured' }, + { status: 500 } + ); + } + + 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: 'Webhook signature verification failed' }, + { status: 400 } + ); + } + + // Handle the event + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + console.log('Checkout completed:', { + customerId: session.customer, + subscriptionId: session.subscription, + userId: session.metadata?.userId, + plan: session.metadata?.plan, + }); + + // TODO: Update user's subscription in your database + // await updateUserSubscription(session.metadata?.userId, { + // plan: session.metadata?.plan, + // stripeCustomerId: session.customer, + // stripeSubscriptionId: session.subscription, + // status: 'active', + // }); + break; + } + + case 'customer.subscription.updated': { + const subscription = event.data.object as Stripe.Subscription; + console.log('Subscription updated:', { + subscriptionId: subscription.id, + status: subscription.status, + customerId: subscription.customer, + plan: subscription.metadata?.plan, + }); + + // TODO: Update subscription status in your database + // await updateSubscriptionStatus(subscription.id, subscription.status); + break; + } + + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription; + console.log('Subscription canceled:', { + subscriptionId: subscription.id, + customerId: subscription.customer, + }); + + // TODO: Handle subscription cancellation + // await cancelUserSubscription(subscription.metadata?.userId); + break; + } + + case 'invoice.payment_succeeded': { + const invoice = event.data.object as Stripe.Invoice; + console.log('Payment succeeded:', { + invoiceId: invoice.id, + customerId: invoice.customer, + amountPaid: invoice.amount_paid, + }); + + // TODO: Record successful payment + // await recordPayment(invoice); + break; + } + + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice; + console.log('Payment failed:', { + invoiceId: invoice.id, + customerId: invoice.customer, + }); + + // TODO: Handle failed payment (send notification, update status) + // await handleFailedPayment(invoice); + break; + } + + default: + console.log(`Unhandled event type: ${event.type}`); + } + + 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/stripe.ts b/web/src/lib/stripe.ts new file mode 100644 index 0000000..8bb3ff1 --- /dev/null +++ b/web/src/lib/stripe.ts @@ -0,0 +1,42 @@ +import Stripe from 'stripe'; + +if (!process.env.STRIPE_SECRET_KEY) { + throw new Error('STRIPE_SECRET_KEY is not set in environment variables'); +} + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2024-12-18.acacia', + typescript: true, +}); + +// Price IDs - these should be created in your Stripe dashboard +// and set as environment variables +export const PRICE_IDS = { + monthly: process.env.STRIPE_PRICE_MONTHLY || 'price_monthly_placeholder', + annual: process.env.STRIPE_PRICE_ANNUAL || 'price_annual_placeholder', +}; + +// 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, + stripePriceId: PRICE_IDS.monthly, + features: ['50 analyses per month', 'Advanced AI models', 'Priority processing', 'Email support'], + }, + annual: { + name: 'Annual', + price: 89, + analyses: -1, // unlimited + stripePriceId: PRICE_IDS.annual, + features: ['Unlimited analyses', 'All AI models + API', 'White-label reports', 'Priority support', '2 months free'], + }, +}; + +export type PlanType = keyof typeof PLANS; From 8417e038fa9efe452ad16145b19751853bfe9bee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 04:31:40 +0000 Subject: [PATCH 2/2] feat(payments): replace Stripe with LemonSqueezy - Remove stripe package dependency - Add LemonSqueezy API integration in /lib/lemonsqueezy.ts - Update checkout endpoint to use LemonSqueezy checkouts API - Update portal endpoint to fetch customer portal URL - Update webhook handler for LemonSqueezy events - Support subscription lifecycle events (created, updated, cancelled, etc.) Required environment variables: - LEMONSQUEEZY_API_KEY: Your LemonSqueezy API key - LEMONSQUEEZY_STORE_ID: Your store ID - LEMONSQUEEZY_VARIANT_MONTHLY: Variant ID for monthly plan - LEMONSQUEEZY_VARIANT_ANNUAL: Variant ID for annual plan - LEMONSQUEEZY_WEBHOOK_SECRET: Webhook signing secret https://claude.ai/code/session_011NWj4qHMpnJqseDYB5et8R --- web/package-lock.json | 20 +- web/package.json | 3 +- .../v1/subscription/create-checkout/route.ts | 70 ++----- .../app/api/v1/subscription/portal/route.ts | 42 ++-- .../app/api/v1/subscription/webhook/route.ts | 187 +++++++++++++----- web/src/lib/lemonsqueezy.ts | 168 ++++++++++++++++ web/src/lib/stripe.ts | 42 ---- 7 files changed, 351 insertions(+), 181 deletions(-) create mode 100644 web/src/lib/lemonsqueezy.ts delete mode 100644 web/src/lib/stripe.ts diff --git a/web/package-lock.json b/web/package-lock.json index e95dc2b..832bfd1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,8 +15,7 @@ "next": "16.1.6", "next-auth": "^4.24.13", "react": "19.2.3", - "react-dom": "19.2.3", - "stripe": "^20.3.0" + "react-dom": "19.2.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -6546,23 +6545,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stripe": { - "version": "20.3.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.0.tgz", - "integrity": "sha512-DYzcmV1MfYhycr1GwjCjeQVYk9Gu8dpxyTlu7qeDCsuguug7oUTxPsUQuZeSf/OPzK7pofqobvOKVqAwlpgf/Q==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "@types/node": ">=16" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", diff --git a/web/package.json b/web/package.json index 97f3b25..92dac0c 100644 --- a/web/package.json +++ b/web/package.json @@ -16,8 +16,7 @@ "next": "16.1.6", "next-auth": "^4.24.13", "react": "19.2.3", - "react-dom": "19.2.3", - "stripe": "^20.3.0" + "react-dom": "19.2.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/web/src/app/api/v1/subscription/create-checkout/route.ts b/web/src/app/api/v1/subscription/create-checkout/route.ts index 0e0adee..7c9bac4 100644 --- a/web/src/app/api/v1/subscription/create-checkout/route.ts +++ b/web/src/app/api/v1/subscription/create-checkout/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; -import { stripe, PLANS, PlanType } from '@/lib/stripe'; +import { createCheckout, PLANS, PlanType } from '@/lib/lemonsqueezy'; export async function POST(request: NextRequest) { try { @@ -26,72 +26,36 @@ export async function POST(request: NextRequest) { } const selectedPlan = PLANS[plan]; - if (!('stripePriceId' in selectedPlan)) { + if (!('variantId' in selectedPlan)) { return NextResponse.json( { error: 'Plan does not have a price configured' }, { status: 400 } ); } - // Check if price ID is configured - if (selectedPlan.stripePriceId.includes('placeholder')) { - return NextResponse.json( - { error: 'Stripe price ID not configured. Please set STRIPE_PRICE_MONTHLY and STRIPE_PRICE_ANNUAL environment variables.' }, - { status: 500 } - ); - } - - // Get or create Stripe customer - const customers = await stripe.customers.list({ + // Create checkout session with LemonSqueezy + const result = await createCheckout({ + variantId: selectedPlan.variantId, email: session.user.email, - limit: 1, - }); - - let customerId: string; - if (customers.data.length > 0) { - customerId = customers.data[0].id; - } else { - const customer = await stripe.customers.create({ - email: session.user.email, - name: session.user.name || undefined, - metadata: { - userId: session.user.email, - }, - }); - customerId = customer.id; - } - - // Create checkout session - const checkoutSession = await stripe.checkout.sessions.create({ - customer: customerId, - mode: 'subscription', - payment_method_types: ['card'], - line_items: [ - { - price: selectedPlan.stripePriceId, - quantity: 1, - }, - ], - success_url: `${process.env.NEXTAUTH_URL}/settings/subscription?success=true&session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${process.env.NEXTAUTH_URL}/settings/subscription?canceled=true`, - metadata: { - userId: session.user.email, + name: session.user.name || undefined, + customData: { + user_email: session.user.email, plan: plan, }, - subscription_data: { - metadata: { - userId: session.user.email, - plan: plan, - }, - }, }); + if ('error' in result) { + return NextResponse.json( + { error: result.error }, + { status: 500 } + ); + } + return NextResponse.json({ - url: checkoutSession.url, - sessionId: checkoutSession.id, + url: result.checkoutUrl, }); } catch (error) { - console.error('Stripe checkout error:', error); + console.error('Checkout error:', error); if (error instanceof Error) { return NextResponse.json( diff --git a/web/src/app/api/v1/subscription/portal/route.ts b/web/src/app/api/v1/subscription/portal/route.ts index 6829928..f1d2437 100644 --- a/web/src/app/api/v1/subscription/portal/route.ts +++ b/web/src/app/api/v1/subscription/portal/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; -import { stripe } from '@/lib/stripe'; +import { lemonSqueezyApi, STORE_ID } from '@/lib/lemonsqueezy'; export async function POST(request: NextRequest) { try { @@ -15,31 +15,43 @@ export async function POST(request: NextRequest) { } // Find customer by email - const customers = await stripe.customers.list({ - email: session.user.email, - limit: 1, - }); + const customersResponse = await lemonSqueezyApi( + `/customers?filter[store_id]=${STORE_ID}&filter[email]=${encodeURIComponent(session.user.email)}` + ); - if (customers.data.length === 0) { + 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 customerId = customers.data[0].id; + const customer = customersData.data[0]; + const portalUrl = customer.attributes.urls?.customer_portal; - // Create billing portal session - const portalSession = await stripe.billingPortal.sessions.create({ - customer: customerId, - return_url: `${process.env.NEXTAUTH_URL}/settings/subscription`, - }); + if (!portalUrl) { + return NextResponse.json( + { error: 'Customer portal not available' }, + { status: 404 } + ); + } return NextResponse.json({ - url: portalSession.url, + url: portalUrl, }); } catch (error) { - console.error('Stripe portal error:', error); + console.error('Portal error:', error); if (error instanceof Error) { return NextResponse.json( @@ -49,7 +61,7 @@ export async function POST(request: NextRequest) { } return NextResponse.json( - { error: 'Failed to create billing portal session' }, + { 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 index aa53576..4789d1a 100644 --- a/web/src/app/api/v1/subscription/webhook/route.ts +++ b/web/src/app/api/v1/subscription/webhook/route.ts @@ -1,117 +1,204 @@ import { NextRequest, NextResponse } from 'next/server'; -import { stripe } from '@/lib/stripe'; -import Stripe from 'stripe'; +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('stripe-signature'); + const signature = request.headers.get('x-signature'); if (!signature) { return NextResponse.json( - { error: 'Missing stripe-signature header' }, + { error: 'Missing x-signature header' }, { status: 400 } ); } - const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + const webhookSecret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET; if (!webhookSecret) { - console.error('STRIPE_WEBHOOK_SECRET is not set'); + console.error('LEMONSQUEEZY_WEBHOOK_SECRET is not set'); return NextResponse.json( { error: 'Webhook secret not configured' }, { status: 500 } ); } - let event: Stripe.Event; + // 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 { - event = stripe.webhooks.constructEvent(body, signature, webhookSecret); - } catch (err) { - console.error('Webhook signature verification failed:', err); + payload = JSON.parse(body); + } catch { return NextResponse.json( - { error: 'Webhook signature verification failed' }, + { error: 'Invalid JSON payload' }, { status: 400 } ); } + const eventName = payload.meta.event_name; + const customData = payload.meta.custom_data; + // Handle the event try { - switch (event.type) { - case 'checkout.session.completed': { - const session = event.data.object as Stripe.Checkout.Session; - console.log('Checkout completed:', { - customerId: session.customer, - subscriptionId: session.subscription, - userId: session.metadata?.userId, - plan: session.metadata?.plan, + 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(session.metadata?.userId, { - // plan: session.metadata?.plan, - // stripeCustomerId: session.customer, - // stripeSubscriptionId: session.subscription, + // await updateUserSubscription(customData?.user_email, { + // plan: customData?.plan, + // subscriptionId: payload.data.id, // status: 'active', // }); break; } - case 'customer.subscription.updated': { - const subscription = event.data.object as Stripe.Subscription; + case 'subscription_updated': { console.log('Subscription updated:', { - subscriptionId: subscription.id, - status: subscription.status, - customerId: subscription.customer, - plan: subscription.metadata?.plan, + subscriptionId: payload.data.id, + status: payload.data.attributes.status, + renewsAt: payload.data.attributes.renews_at, }); - // TODO: Update subscription status in your database - // await updateSubscriptionStatus(subscription.id, subscription.status); + // TODO: Update subscription status + // await updateSubscriptionStatus(payload.data.id, payload.data.attributes.status); break; } - case 'customer.subscription.deleted': { - const subscription = event.data.object as Stripe.Subscription; - console.log('Subscription canceled:', { - subscriptionId: subscription.id, - customerId: subscription.customer, + 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 cancelUserSubscription(subscription.metadata?.userId); + // await handleSubscriptionEnd(payload.data.id); break; } - case 'invoice.payment_succeeded': { - const invoice = event.data.object as Stripe.Invoice; + case 'subscription_payment_success': { console.log('Payment succeeded:', { - invoiceId: invoice.id, - customerId: invoice.customer, - amountPaid: invoice.amount_paid, + subscriptionId: payload.data.id, + customerId: payload.data.attributes.customer_id, }); - // TODO: Record successful payment - // await recordPayment(invoice); + // TODO: Record successful payment, extend access + // await recordPayment(payload.data.id, 'success'); break; } - case 'invoice.payment_failed': { - const invoice = event.data.object as Stripe.Invoice; + case 'subscription_payment_failed': { console.log('Payment failed:', { - invoiceId: invoice.id, - customerId: invoice.customer, + subscriptionId: payload.data.id, + customerId: payload.data.attributes.customer_id, }); // TODO: Handle failed payment (send notification, update status) - // await handleFailedPayment(invoice); + // 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: ${event.type}`); + console.log(`Unhandled event type: ${eventName}`); } return NextResponse.json({ received: true }); 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)); +} diff --git a/web/src/lib/stripe.ts b/web/src/lib/stripe.ts deleted file mode 100644 index 8bb3ff1..0000000 --- a/web/src/lib/stripe.ts +++ /dev/null @@ -1,42 +0,0 @@ -import Stripe from 'stripe'; - -if (!process.env.STRIPE_SECRET_KEY) { - throw new Error('STRIPE_SECRET_KEY is not set in environment variables'); -} - -export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { - apiVersion: '2024-12-18.acacia', - typescript: true, -}); - -// Price IDs - these should be created in your Stripe dashboard -// and set as environment variables -export const PRICE_IDS = { - monthly: process.env.STRIPE_PRICE_MONTHLY || 'price_monthly_placeholder', - annual: process.env.STRIPE_PRICE_ANNUAL || 'price_annual_placeholder', -}; - -// 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, - stripePriceId: PRICE_IDS.monthly, - features: ['50 analyses per month', 'Advanced AI models', 'Priority processing', 'Email support'], - }, - annual: { - name: 'Annual', - price: 89, - analyses: -1, // unlimited - stripePriceId: PRICE_IDS.annual, - features: ['Unlimited analyses', 'All AI models + API', 'White-label reports', 'Priority support', '2 months free'], - }, -}; - -export type PlanType = keyof typeof PLANS;