Feature: Royal Mail shipping integration with weight-based postage
Added: - Weight field (weightGrams) to Product model for storing item weights - Shipping fields to Order model (shippingCost, shippingCountry, shippingService) - Royal Mail shipping service (royalmail-shipping.ts) with: * Domestic and international postage calculations * Integration ready for Royal Mail API (currently uses estimated rates) * Support for multiple shipping services * Clear international delivery time warnings - Shipping quotes API endpoint (/api/checkout/shipping-quotes) - Checkout integration to calculate and include shipping cost Weight is stored in grams, displayed to users in kilograms. Shipping cost is calculated based on total cart weight and destination country.
This commit is contained in:
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
type CheckoutCartItem = {
|
||||
productId: string;
|
||||
@@ -54,6 +55,8 @@ export async function POST(req: Request) {
|
||||
console.log('Checkout request received with items:', body.items?.length, 'customerId:', body.customerId);
|
||||
const items: CheckoutCartItem[] = body.items ?? [];
|
||||
const guest: GuestInfo | undefined = body.guest;
|
||||
const shippingCountry: string = body.shippingCountry ?? 'GB';
|
||||
const shippingService: string | undefined = body.shippingService;
|
||||
|
||||
if (items.length === 0) {
|
||||
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
|
||||
@@ -86,7 +89,53 @@ export async function POST(req: Request) {
|
||||
};
|
||||
});
|
||||
|
||||
const total = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
|
||||
const subtotal = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
|
||||
|
||||
// Calculate shipping cost
|
||||
let shippingCost = 0;
|
||||
let selectedShippingService = shippingService;
|
||||
|
||||
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;
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
// Save the order up front, before redirecting to Stripe — this is what makes
|
||||
@@ -96,6 +145,9 @@ export async function POST(req: Request) {
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
total,
|
||||
shippingCost,
|
||||
shippingCountry,
|
||||
shippingService: selectedShippingService,
|
||||
customerId: customer?.id,
|
||||
email: guest?.email ?? customer?.email,
|
||||
guestName: guest?.name,
|
||||
@@ -123,19 +175,36 @@ export async function POST(req: Request) {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
||||
|
||||
try {
|
||||
console.log('Creating Stripe session for order:', order.id, 'total:', total);
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'payment',
|
||||
line_items: pricedItems.map((i) => ({
|
||||
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: i.unitPrice,
|
||||
unit_amount: shippingCost,
|
||||
product_data: {
|
||||
name: `${i.name} — ${i.color}${i.size ? `, ${i.size}` : ''}`,
|
||||
name: `Royal Mail Shipping${selectedShippingService ? ` — ${selectedShippingService}` : ''}`,
|
||||
},
|
||||
},
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
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'],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getRoyalMailShippingQuotes, formatShippingPrice } from '@/lib/royalmail-shipping';
|
||||
|
||||
interface CartItem {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { items, country } = await req.json();
|
||||
|
||||
if (!items || !Array.isArray(items) || !country) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing items or country' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// If no weight is set for products, return default shipping
|
||||
if (totalWeightGrams === 0) {
|
||||
totalWeightGrams = 500; // Default 500g if not specified
|
||||
}
|
||||
|
||||
// Get Royal Mail shipping quotes
|
||||
const quotes = await getRoyalMailShippingQuotes(totalWeightGrams, country);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
totalWeightGrams,
|
||||
totalWeightKg: (totalWeightGrams / 1000).toFixed(2),
|
||||
country,
|
||||
quotes: quotes.map((q) => ({
|
||||
...q,
|
||||
priceFormatted: formatShippingPrice(q.price),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error calculating shipping quotes:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to calculate shipping' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Royal Mail shipping integration for domestic and international postage
|
||||
|
||||
export interface ShippingQuote {
|
||||
service: string;
|
||||
price: number; // in pence
|
||||
estimatedDays: number;
|
||||
isInternational: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
// UK postal areas
|
||||
const UK_COUNTRIES = ['GB', 'UK'];
|
||||
|
||||
// Royal Mail standard services - these are approximate rates, will be updated via API
|
||||
// when Royal Mail credentials are available
|
||||
const DOMESTIC_RATES = {
|
||||
// Special Delivery Guaranteed by 9am next working day
|
||||
special_next_day: { basePrice: 895, perKg: 0 }, // £8.95 flat
|
||||
// Special Delivery Guaranteed by 9am Saturday
|
||||
special_saturday: { basePrice: 895, perKg: 0 }, // £8.95 flat
|
||||
// Royal Mail 24® - Next working day
|
||||
royal_24: { basePrice: 495, perKg: 0 }, // £4.95 flat
|
||||
// Royal Mail 48® - 2-3 working days
|
||||
royal_48: { basePrice: 295, perKg: 0 }, // £2.95 flat
|
||||
};
|
||||
|
||||
const INTERNATIONAL_RATES = {
|
||||
// International Signed
|
||||
international_signed: { basePrice: 1495, perKg: 300 }, // £14.95 + £3.00 per kg
|
||||
// International Economy
|
||||
international_economy: { basePrice: 1095, perKg: 200 }, // £10.95 + £2.00 per kg
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate Royal Mail postage cost based on weight and destination
|
||||
* @param weightGrams Total weight in grams
|
||||
* @param destinationCountry ISO 2-letter country code (e.g., 'GB', 'US')
|
||||
* @returns Array of available shipping options with prices
|
||||
*/
|
||||
export async function getRoyalMailShippingQuotes(
|
||||
weightGrams: number,
|
||||
destinationCountry: string
|
||||
): Promise<ShippingQuote[]> {
|
||||
const weightKg = weightGrams / 1000;
|
||||
const isInternational = !UK_COUNTRIES.includes(destinationCountry.toUpperCase());
|
||||
|
||||
// TODO: Replace with actual Royal Mail API call when credentials are available
|
||||
// const apiKey = process.env.ROYAL_MAIL_API_KEY;
|
||||
// if (apiKey) {
|
||||
// return await fetchRealRoyalMailRates(weightKg, destinationCountry, apiKey);
|
||||
// }
|
||||
|
||||
// Fallback to estimated rates
|
||||
return calculateEstimatedRates(weightKg, isInternational, destinationCountry);
|
||||
}
|
||||
|
||||
function calculateEstimatedRates(
|
||||
weightKg: number,
|
||||
isInternational: boolean,
|
||||
destinationCountry: string
|
||||
): ShippingQuote[] {
|
||||
const quotes: ShippingQuote[] = [];
|
||||
|
||||
if (isInternational) {
|
||||
// International options
|
||||
quotes.push({
|
||||
service: 'International Signed',
|
||||
price: Math.ceil(
|
||||
INTERNATIONAL_RATES.international_signed.basePrice +
|
||||
INTERNATIONAL_RATES.international_signed.perKg * Math.ceil(weightKg)
|
||||
),
|
||||
estimatedDays: 14,
|
||||
isInternational: true,
|
||||
warning: 'International delivery typically takes 10-14 working days to most destinations',
|
||||
});
|
||||
|
||||
quotes.push({
|
||||
service: 'International Economy',
|
||||
price: Math.ceil(
|
||||
INTERNATIONAL_RATES.international_economy.basePrice +
|
||||
INTERNATIONAL_RATES.international_economy.perKg * Math.ceil(weightKg)
|
||||
),
|
||||
estimatedDays: 21,
|
||||
isInternational: true,
|
||||
warning: 'Economy international delivery can take 2-3 weeks',
|
||||
});
|
||||
} else {
|
||||
// UK domestic options
|
||||
quotes.push({
|
||||
service: 'Special Delivery Guaranteed by 9am',
|
||||
price: DOMESTIC_RATES.special_next_day.basePrice,
|
||||
estimatedDays: 1,
|
||||
isInternational: false,
|
||||
});
|
||||
|
||||
quotes.push({
|
||||
service: 'Royal Mail 24®',
|
||||
price: DOMESTIC_RATES.royal_24.basePrice,
|
||||
estimatedDays: 1,
|
||||
isInternational: false,
|
||||
});
|
||||
|
||||
quotes.push({
|
||||
service: 'Royal Mail 48®',
|
||||
price: DOMESTIC_RATES.royal_48.basePrice,
|
||||
estimatedDays: 3,
|
||||
isInternational: false,
|
||||
});
|
||||
}
|
||||
|
||||
return quotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Implement actual Royal Mail API integration
|
||||
* This would call Royal Mail's postage API with real-time rates
|
||||
* Requires Royal Mail credentials and API access
|
||||
*/
|
||||
// async function fetchRealRoyalMailRates(
|
||||
// weightKg: number,
|
||||
// destinationCountry: string,
|
||||
// apiKey: string
|
||||
// ): Promise<ShippingQuote[]> {
|
||||
// const endpoint = 'https://api.royalmail.com/shipping/rates';
|
||||
// // Implementation would call Royal Mail API
|
||||
// }
|
||||
|
||||
/**
|
||||
* Format price from pence to pounds string
|
||||
*/
|
||||
export function formatShippingPrice(pence: number): string {
|
||||
return `£${(pence / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delivery estimate text with international warning if needed
|
||||
*/
|
||||
export function getDeliveryEstimate(quote: ShippingQuote): string {
|
||||
if (quote.warning) {
|
||||
return quote.warning;
|
||||
}
|
||||
return `Estimated delivery: ${quote.estimatedDays} working day${quote.estimatedDays > 1 ? 's' : ''}`;
|
||||
}
|
||||
Reference in New Issue
Block a user