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:
Andymick
2026-07-19 06:46:35 +01:00
parent 9b9745e6e2
commit e150f2aed9
7 changed files with 296 additions and 12 deletions
+78 -9
View File
@@ -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 }
);
}
}
+143
View File
@@ -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' : ''}`;
}