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>
280 lines
9.7 KiB
TypeScript
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 });
|
|
}
|
|
}
|