letsbe-hub/src/app/api/v1/webhooks/stripe/route.ts

212 lines
6.1 KiB
TypeScript
Raw Normal View History

import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { stripeService } from '@/lib/services/stripe-service'
import {
OrderStatus,
AutomationMode,
SubscriptionStatus,
UserStatus,
} from '@prisma/client'
import { emailService } from '@/lib/services/email-service'
import { randomBytes } from 'crypto'
import bcrypt from 'bcryptjs'
/**
* Generate a customer ID slug from company/user name
* Only lowercase letters allowed (env_setup.sh requires ^[a-z]+$)
*/
function slugifyCustomer(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z]/g, '')
.substring(0, 32)
}
/**
* Generate a unique license key for the order
*/
function generateLicenseKey(): string {
const hex = randomBytes(16).toString('hex')
return `lb_inst_${hex}`
}
/**
* POST /api/v1/webhooks/stripe
*
* Handles Stripe webhook events. The primary event is checkout.session.completed,
* which creates a Customer, Order, and Subscription in the Hub database.
*
* Important: The raw body must be passed to constructEvent for signature verification.
* Next.js App Router provides the raw body via request.text().
*/
export async function POST(request: NextRequest) {
if (!stripeService.isConfigured()) {
console.error('Stripe webhook received but Stripe is not configured')
return NextResponse.json(
{ error: 'Stripe is not configured' },
{ status: 500 }
)
}
// Get the raw body as text for signature verification
const rawBody = await request.text()
// Get the Stripe signature header
const signature = request.headers.get('stripe-signature')
if (!signature) {
return NextResponse.json(
{ error: 'Missing stripe-signature header' },
{ status: 400 }
)
}
// Verify webhook signature and construct event
let event
try {
event = stripeService.constructWebhookEvent(rawBody, signature)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
console.error(`Stripe webhook signature verification failed: ${message}`)
return NextResponse.json(
{ error: `Webhook signature verification failed: ${message}` },
{ status: 400 }
)
}
// Handle the event
try {
switch (event.type) {
case 'checkout.session.completed': {
await handleCheckoutSessionCompleted(event.data.object)
break
}
default:
// Acknowledge other events without processing
console.log(`Unhandled Stripe event type: ${event.type}`)
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
console.error(`Error handling Stripe event ${event.type}: ${message}`)
return NextResponse.json(
{ error: `Failed to handle event: ${message}` },
{ status: 500 }
)
}
return NextResponse.json({ received: true })
}
/**
* Handle checkout.session.completed event
*
* 1. Find or create User by email
* 2. Create Subscription with Stripe IDs
* 3. Create Order with PAYMENT_CONFIRMED status
*/
async function handleCheckoutSessionCompleted(
session: Record<string, unknown>
) {
const customerEmail = session.customer_email as string | undefined
const customerName = (session.metadata as Record<string, string>)?.customer_name
const customerDomain = (session.metadata as Record<string, string>)?.domain
const stripeCustomerId = session.customer as string | undefined
const stripeSubscriptionId = session.subscription as string | undefined
if (!customerEmail) {
throw new Error('checkout.session.completed missing customer_email')
}
// Extract plan info from session metadata
const planMapping = stripeService.extractPlanFromSession(
session as unknown as import('stripe').Stripe.Checkout.Session
)
if (!planMapping) {
throw new Error(
'Could not determine plan from checkout session. ' +
'Ensure metadata includes price_id or plan.'
)
}
// 1. Find or create User
let user = await prisma.user.findUnique({
where: { email: customerEmail },
})
if (!user) {
const randomPassword = randomBytes(16).toString('hex')
const passwordHash = await bcrypt.hash(randomPassword, 10)
user = await prisma.user.create({
data: {
email: customerEmail,
name: customerName || null,
passwordHash,
status: UserStatus.ACTIVE,
},
})
}
// 2. Create Subscription
await prisma.subscription.create({
data: {
userId: user.id,
plan: planMapping.plan,
tier: planMapping.tier,
tokenLimit: planMapping.tokenLimit,
status: SubscriptionStatus.ACTIVE,
stripeCustomerId: stripeCustomerId || null,
stripeSubscriptionId: stripeSubscriptionId || null,
},
})
// 3. Create Order
const displayName = customerName || user.company || user.name || 'customer'
const customerSlug = slugifyCustomer(displayName) || 'customer'
const domain = customerDomain || `${customerSlug}.letsbe.cloud`
const licenseKey = generateLicenseKey()
await prisma.order.create({
data: {
userId: user.id,
domain,
tier: planMapping.tier,
tools: planMapping.tools,
status: OrderStatus.PAYMENT_CONFIRMED,
automationMode: AutomationMode.AUTO,
source: 'stripe',
customer: customerSlug,
companyName: displayName,
licenseKey,
configJson: {
tools: planMapping.tools,
tier: planMapping.tier,
domain,
stripeSessionId: session.id,
stripeCustomerId,
stripeSubscriptionId,
},
},
})
console.log(
`Stripe checkout completed: created order for ${customerEmail} ` +
`(plan: ${planMapping.plan}, domain: ${domain})`
)
// 4. Send welcome email (non-blocking - don't fail webhook on email error)
try {
const emailResult = await emailService.sendWelcomeEmail({
to: customerEmail,
customerName: customerName || user.name || '',
plan: planMapping.plan,
domain,
})
if (!emailResult.success) {
console.warn(`Welcome email failed for ${customerEmail}: ${emailResult.error}`)
}
} catch (emailErr) {
console.warn('Failed to send welcome email:', emailErr)
}
}