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
+2 -1
View File
@@ -85,7 +85,8 @@
"Bash(git commit -m 'Fix: Pass photo request ID instead of data URL *)", "Bash(git commit -m 'Fix: Pass photo request ID instead of data URL *)",
"Bash(git commit -m 'Feature: Add product selection when starting design from photo request *)", "Bash(git commit -m 'Feature: Add product selection when starting design from photo request *)",
"Bash(git commit -m 'Refactor: Move start design server action to separate file *)", "Bash(git commit -m 'Refactor: Move start design server action to separate file *)",
"Bash(git commit -m 'Add: Prominent '\\\\''Shop plain items'\\\\'' section to homepage *)" "Bash(git commit -m 'Add: Prominent '\\\\''Shop plain items'\\\\'' section to homepage *)",
"Bash(git commit -m 'Feature: Royal Mail shipping integration with weight-based postage *)"
] ]
} }
} }
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "weightGrams" INTEGER;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "shippingCost" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "shippingCountry" TEXT,
ADD COLUMN "shippingService" TEXT,
ALTER COLUMN "carrier" SET DEFAULT 'Royal Mail';
+6 -2
View File
@@ -41,6 +41,7 @@ model Product {
printAreaHeightMm Int @default(280) // height of the printable area in millimeters printAreaHeightMm Int @default(280) // height of the printable area in millimeters
referenceWidthCm Int? // width of garment/object for ruler reference (cm) referenceWidthCm Int? // width of garment/object for ruler reference (cm)
referenceHeightCm Int? // height of garment/object for ruler reference (cm) referenceHeightCm Int? // height of garment/object for ruler reference (cm)
weightGrams Int? // weight in grams (used for Royal Mail postage calculation via API)
imageUrl String? // optional real product photo — shown instead of the illustration when set imageUrl String? // optional real product photo — shown instead of the illustration when set
imageUrlBack String? // optional back-view photo, toggled alongside the front photo imageUrlBack String? // optional back-view photo, toggled alongside the front photo
photoRecolorable Boolean @default(false) // if true, the photo is treated as a grayscale photoRecolorable Boolean @default(false) // if true, the photo is treated as a grayscale
@@ -218,12 +219,15 @@ model Order {
stripeSessionId String? @unique stripeSessionId String? @unique
email String? email String?
shippingAddress String? // JSON — collected by Stripe Checkout, saved once payment completes shippingAddress String? // JSON — collected by Stripe Checkout, saved once payment completes
shippingCountry String? // ISO 2-letter country code (e.g., 'GB', 'US') for Royal Mail shipping
status String @default("PENDING") // PENDING | PAID | PRINTING | PRINTED | SHIPPED | DELIVERED | FAILED status String @default("PENDING") // PENDING | PAID | PRINTING | PRINTED | SHIPPED | DELIVERED | FAILED
archived Boolean @default(false) archived Boolean @default(false)
total Int // cents total Int // cents (includes shipping cost)
shippingCost Int @default(0) // cents — Royal Mail postage cost
// Shipping & tracking info // Shipping & tracking info
carrier String? // e.g. "Royal Mail", "DPD", "Courier" shippingService String? // e.g. "Royal Mail 24®", "International Signed" — selected by customer at checkout
carrier String? @default("Royal Mail") // e.g. "Royal Mail", "DPD", "Courier"
trackingNumber String? // tracking number from carrier trackingNumber String? // tracking number from carrier
estimatedDeliveryDate DateTime? // estimated delivery date estimatedDeliveryDate DateTime? // estimated delivery date
+75 -6
View File
@@ -5,6 +5,7 @@ import { getCurrentCustomer } from '@/lib/auth';
import { getEffectivePrice } from '@/lib/pricing'; import { getEffectivePrice } from '@/lib/pricing';
import { fetchActivePromotions } from '@/lib/promotions'; import { fetchActivePromotions } from '@/lib/promotions';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit'; import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { getRoyalMailShippingQuotes } from '@/lib/royalmail-shipping';
type CheckoutCartItem = { type CheckoutCartItem = {
productId: string; 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); console.log('Checkout request received with items:', body.items?.length, 'customerId:', body.customerId);
const items: CheckoutCartItem[] = body.items ?? []; const items: CheckoutCartItem[] = body.items ?? [];
const guest: GuestInfo | undefined = body.guest; const guest: GuestInfo | undefined = body.guest;
const shippingCountry: string = body.shippingCountry ?? 'GB';
const shippingService: string | undefined = body.shippingService;
if (items.length === 0) { if (items.length === 0) {
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 }); 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(); const customer = await getCurrentCustomer();
// Save the order up front, before redirecting to Stripe — this is what makes // Save the order up front, before redirecting to Stripe — this is what makes
@@ -96,6 +145,9 @@ export async function POST(req: Request) {
data: { data: {
status: 'PENDING', status: 'PENDING',
total, total,
shippingCost,
shippingCountry,
shippingService: selectedShippingService,
customerId: customer?.id, customerId: customer?.id,
email: guest?.email ?? customer?.email, email: guest?.email ?? customer?.email,
guestName: guest?.name, guestName: guest?.name,
@@ -123,10 +175,9 @@ export async function POST(req: Request) {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
try { try {
console.log('Creating Stripe session for order:', order.id, 'total:', total); console.log('Creating Stripe session for order:', order.id, 'subtotal:', subtotal, 'shipping:', shippingCost, 'total:', total);
const session = await stripe.checkout.sessions.create({
mode: 'payment', const lineItems = pricedItems.map((i) => ({
line_items: pricedItems.map((i) => ({
price_data: { price_data: {
currency: 'gbp', currency: 'gbp',
unit_amount: i.unitPrice, unit_amount: i.unitPrice,
@@ -135,7 +186,25 @@ export async function POST(req: Request) {
}, },
}, },
quantity: i.quantity, 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: { shipping_address_collection: {
allowed_countries: ['GB', 'IE', 'US', 'CA', 'AU', 'NZ'], 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' : ''}`;
}