212 lines
6.1 KiB
TypeScript
212 lines
6.1 KiB
TypeScript
|
|
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)
|
||
|
|
}
|
||
|
|
}
|