import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { stripe } from '@/lib/stripe'; import { getCurrentCustomer } from '@/lib/auth'; import { getEffectivePrice } from '@/lib/pricing'; import { fetchActivePromotions } from '@/lib/promotions'; import { checkRateLimit, getClientIp } from '@/lib/rateLimit'; import { getRoyalMailShippingQuotes } from '@/lib/royalmail-shipping'; import { cleanupPendingOrders } from '@/lib/cleanupOrders'; type CheckoutCartItem = { productId: string; slug: string; name: string; color: string; size: string | null; quantity: number; unitPrice: number; designJson: { front: unknown[]; back: unknown[] }; designPreviewUrl: string; designPreviewUrlBack: string | null; placementPreviewUrl: string | null; placementPreviewUrlBack: string | null; designWidthCm: number | null; designHeightCm: number | null; }; type GuestInfo = { name: string; email: string; phone: string; address: string; }; export async function POST(req: Request) { try { if (!process.env.STRIPE_SECRET_KEY) { console.error('STRIPE_SECRET_KEY is not configured'); return NextResponse.json( { error: 'Stripe is not configured on this server yet. Add STRIPE_SECRET_KEY to .env.' }, { status: 500 }, ); } const ip = getClientIp(req.headers); const { allowed } = await checkRateLimit(`checkout:${ip}`, 20, 10 * 60 * 1000); if (!allowed) { console.error('Rate limit exceeded for IP:', ip); return NextResponse.json( { error: 'Too many requests — please wait a moment and try again.' }, { status: 429 }, ); } // Clean up old incomplete orders (non-blocking) cleanupPendingOrders().catch((err) => console.error('Cleanup error:', err)); const body = await req.json(); console.log('Checkout request received with items:', body.items?.length, 'customerId:', body.customerId, 'isCollection:', body.isCollection); const items: CheckoutCartItem[] = body.items ?? []; const guest: GuestInfo | undefined = body.guest; const shippingCountry: string = body.shippingCountry ?? 'GB'; const shippingService: string | undefined = body.shippingService; const isCollection: boolean = body.isCollection ?? false; const customer = await getCurrentCustomer(); const checkoutEmail = guest?.email ?? customer?.email; // Delete any other recent PENDING orders for this email to avoid duplicates if (checkoutEmail) { try { const deletedCount = await prisma.order.deleteMany({ where: { email: checkoutEmail, status: 'PENDING', createdAt: { gte: new Date(Date.now() - 2 * 60 * 60 * 1000), // Within last 2 hours }, }, }); if (deletedCount.count > 0) { console.log(`Cleaned up ${deletedCount.count} duplicate pending order(s) for ${checkoutEmail}`); } } catch (err) { console.error('Error deleting duplicate pending orders:', err); } } if (items.length === 0) { return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 }); } // Never trust the client-submitted unitPrice for the actual charge — look up // each product and recompute its current price (including any active sale) // server-side. The client's unitPrice is only used for display before this point. const products = await prisma.product.findMany({ where: { id: { in: items.map((i) => i.productId) } }, }); const productsById = new Map(products.map((p) => [p.id, p])); for (const i of items) { if (!productsById.has(i.productId)) { return NextResponse.json( { error: 'One of the items in your bag is no longer available.' }, { status: 400 }, ); } } const activePromotions = await fetchActivePromotions(); const pricedItems = items.map((i) => { const design = typeof i.designJson === 'string' ? JSON.parse(i.designJson) : i.designJson; const isPersonalised = (design.front?.length ?? 0) > 0 || (design.back?.length ?? 0) > 0; const product = productsById.get(i.productId)!; // Determine which price to use let priceType: 'base' | 'plain' | 'personalised' = 'base'; if (isPersonalised) { priceType = 'personalised'; } else if (product.plainPrice != null) { priceType = 'plain'; } return { ...i, unitPrice: getEffectivePrice(product, activePromotions, new Date(), priceType).price, }; }); const subtotal = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0); // Calculate shipping cost (skip for collection orders) let shippingCost = 0; let selectedShippingService = shippingService; if (isCollection) { console.log('Collection order - no shipping cost'); selectedShippingService = null; } else { try { // Calculate total weight from cart items let totalWeightGrams = 0; for (const item of items) { const product = await prisma.product.findUnique({ where: { id: item.productId }, select: { weightGrams: true }, }); if (product?.weightGrams) { totalWeightGrams += product.weightGrams * item.quantity; } } // Default weight if none specified if (totalWeightGrams === 0) { totalWeightGrams = 500; } // Get available shipping quotes const quotes = await getRoyalMailShippingQuotes(totalWeightGrams, shippingCountry); // Find the selected shipping service or use the first (cheapest) option if (shippingService) { const selectedQuote = quotes.find((q) => q.service === shippingService); if (selectedQuote) { shippingCost = selectedQuote.price; } else { shippingCost = quotes[0].price; selectedShippingService = quotes[0].service; } } else if (quotes.length > 0) { shippingCost = quotes[0].price; selectedShippingService = quotes[0].service; } } catch (error) { console.error('Error calculating shipping:', error); // Default shipping cost if calculation fails shippingCost = 500; // £5.00 } } const total = subtotal + shippingCost; // Save the order up front, before redirecting to Stripe — this is what makes // the design files (and the order itself) retrievable afterward, whether or // not the customer ever makes it back to the success page. const order = await prisma.order.create({ data: { status: 'PENDING', total, shippingCost, shippingCountry, shippingService: selectedShippingService, customerId: customer?.id, email: guest?.email ?? customer?.email, guestName: guest?.name, guestPhone: guest?.phone, isCollectionPickup, items: { create: pricedItems.map((i) => ({ productId: i.productId, productName: i.name, quantity: i.quantity, color: i.color, size: i.size, unitPrice: i.unitPrice, designJson: JSON.stringify(i.designJson), designPreviewUrl: i.designPreviewUrl, designPreviewUrlBack: i.designPreviewUrlBack, placementPreviewUrl: i.placementPreviewUrl, placementPreviewUrlBack: i.placementPreviewUrlBack, designWidthCm: i.designWidthCm, designHeightCm: i.designHeightCm, })), }, }, }); const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'; try { console.log('Creating Stripe session for order:', order.id, 'subtotal:', subtotal, 'shipping:', shippingCost, 'total:', total); const lineItems = pricedItems.map((i) => ({ price_data: { currency: 'gbp', unit_amount: i.unitPrice, product_data: { name: `${i.name} — ${i.color}${i.size ? `, ${i.size}` : ''}`, }, }, quantity: i.quantity, })); // Add shipping as a line item if (shippingCost > 0) { lineItems.push({ price_data: { currency: 'gbp', unit_amount: shippingCost, product_data: { name: `Royal Mail Shipping${selectedShippingService ? ` — ${selectedShippingService}` : ''}`, }, }, quantity: 1, }); } const session = await stripe.checkout.sessions.create({ mode: 'payment', line_items: lineItems, shipping_address_collection: { allowed_countries: ['GB', 'IE', 'US', 'CA', 'AU', 'NZ'], }, customer_email: customer?.email, success_url: `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${siteUrl}/checkout/cancel`, metadata: { orderId: order.id }, }); console.log('Stripe session created:', session.id, 'URL:', session.url); await prisma.order.update({ where: { id: order.id }, data: { stripeSessionId: session.id }, }); return NextResponse.json({ url: session.url }); } catch (err) { // Stripe call failed — don't leave an orphaned PENDING order with no way to pay it. console.error('Stripe session creation failed:', err); await prisma.order.delete({ where: { id: order.id } }); const message = err instanceof Error ? err.message : 'Could not start checkout.'; console.error('Returning error to client:', message); return NextResponse.json({ error: message }, { status: 500 }); } } catch (err) { const message = err instanceof Error ? err.message : 'Something went wrong.'; return NextResponse.json({ error: message }, { status: 500 }); } }