Files
craft2prints/src/app/api/checkout/route.ts
T
AndymickandClaude Haiku 4.5 5bd6f3de39 Feature: Implement context-aware pricing for product types
Updates pricing logic to use correct price based on product type:
- Updated getEffectivePrice to accept priceType parameter ('base', 'plain', 'personalised')
- Updated toProductDTO to accept and use priceType when computing effective price
- Updated made-to-order pages to use 'personalised' price type
- Updated products catalog pages to use 'plain' price type
- Updated checkout logic to determine price type based on design

Pricing behavior:
- Personalised orders (with design): use personalisedPrice if set, else basePrice
- Plain/blank orders (no design): use plainPrice if set, else basePrice
- Ready-made orders (no design, no plainPrice): use basePrice

All prices now correctly reflect the product variant being purchased.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-20 18:16:17 +01:00

280 lines
9.7 KiB
TypeScript

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 });
}
}