full shipping flow

This commit is contained in:
2ManyProjects 2025-04-28 00:54:03 -05:00
parent 57c5b6f864
commit d9953baa19
4 changed files with 152 additions and 132 deletions

View file

@ -475,7 +475,8 @@ module.exports = (pool, query, authMiddleware) => {
// Get cart items with product weights
const cartItemsResult = await query(
`SELECT ci.quantity, p.id, p.weight_grams, p.price
`SELECT ci.quantity, p.id, p.weight_grams, p.price,
p.length_cm, p.width_cm, p.height_cm
FROM cart_items ci
JOIN products p ON ci.product_id = p.id
WHERE ci.cart_id = $1`,
@ -497,34 +498,55 @@ module.exports = (pool, query, authMiddleware) => {
// If no address provided, return only flat rate shipping
if (!shippingAddress) {
console.log("No Address provide flat rate");
const rates = await shippingService.getFlatRateShipping(subtotal);
console.log("No Address provided - using flat rate");
const rates = shippingService.getFlatRateShipping(subtotal);
return res.json({
success: true,
shipment_id: null,
rates
});
}
// Get real shipping rates
// Get real shipping rates with a shipment
const parsedAddress = typeof shippingAddress === 'string'
? shippingService.parseAddressString(shippingAddress)
: shippingAddress;
console.log("parsedAddress provided ", parsedAddress);
const rates = await shippingService.getShippingRates(
console.log("Fetching rates for parsed address:", parsedAddress);
const shippingResponse = await shippingService.getShippingRates(
null, // Use default from config
parsedAddress,
{
weight: totalWeight,
length: Math.max(...cartItemsResult.rows.map(item => item.length_cm || 0)),
width: Math.max(...cartItemsResult.rows.map(item => item.width_cm || 0)),
height: Math.max(...cartItemsResult.rows.map(item => item.height_cm || 0)),
order_total: subtotal
}
);
console.log("rates provided ", JSON.stringify(rates, null ,4));
console.log("Shipping rates response:", JSON.stringify(shippingResponse, null, 4));
// Save the shipment ID to the session or temporary storage
// This will be used when the user selects a rate
if (shippingResponse.shipment_id) {
// Store the shipment ID in the user's cart for later use
await query(
`UPDATE carts SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb),
'{temp_shipment_id}',
$1::jsonb
) WHERE id = $2`,
[JSON.stringify(shippingResponse.shipment_id), cartId]
);
}
res.json({
success: true,
rates
shipment_id: shippingResponse.shipment_id,
rates: shippingResponse.rates
});
} catch (error) {
console.error('Error getting shipping rates:', error);
@ -595,49 +617,54 @@ module.exports = (pool, query, authMiddleware) => {
if (config.shipping.enabled) {
// If a specific shipping method was selected
if (shippingMethod && shippingMethod.id) {
// Parse shipping address to object if it's a string
const parsedAddress = typeof shippingAddress === 'string'
? shippingService.parseAddressString(shippingAddress)
: shippingAddress;
// Calculate total weight
const totalWeight = shippingService.calculateTotalWeight(cartItemsResult.rows);
shippingCost = parseFloat(shippingMethod.rate) || 0;
// Create an actual shipment on EasyPost if a real rate was selected (not flat/free)
// Check if this is a non-flat rate and we need to purchase a real shipment
if (config.shipping.easypostEnabled &&
!shippingMethod.id.includes('flat-rate') &&
!shippingMethod.id.includes('free-shipping')) {
try {
// Create parcel details
const parcelDetails = {
weight: totalWeight,
order_total: subtotal
};
// Create shipment with the selected rate
shipmentData = await shippingService.createShipment(
null, // Use default origin
parsedAddress,
parcelDetails,
shippingMethod.id
// Parse shipping address to object if it's a string
const parsedAddress = typeof shippingAddress === 'string'
? shippingService.parseAddressString(shippingAddress)
: shippingAddress;
// Retrieve temporary shipment ID from cart metadata
const cartMetadataResult = await query(
'SELECT metadata FROM carts WHERE id = $1',
[cartId]
);
// Use the actual rate from the created shipment
shippingCost = shipmentData.selected_rate.rate;
let shipmentId = null;
if (cartMetadataResult.rows.length > 0 &&
cartMetadataResult.rows[0].metadata &&
cartMetadataResult.rows[0].metadata.temp_shipment_id) {
shipmentId = cartMetadataResult.rows[0].metadata.temp_shipment_id;
}
console.log('Shipment created successfully:', shipmentData.shipment_id);
if (shipmentId) {
// Purchase the shipment with the selected rate
shipmentData = await shippingService.purchaseShipment(
shipmentId,
shippingMethod.id
);
console.log('Shipment purchased successfully:', shipmentData.shipment_id);
// Use the actual rate from the purchased shipment
shippingCost = shipmentData.selected_rate.rate;
} else {
console.log('No shipment ID found in cart metadata, using standard rate');
}
} catch (error) {
console.error('Error creating shipment:', error);
// Fallback to the rate provided
shippingCost = parseFloat(shippingMethod.rate) || 0;
console.error('Error purchasing shipment:', error);
// Continue with the rate provided
}
} else {
// Use the rate provided for flat rate or free shipping
shippingCost = parseFloat(shippingMethod.rate) || 0;
}
} else {
// Default to flat rate if no method selected
const shippingRates = await shippingService.getFlatRateShipping(subtotal);
const shippingRates = shippingService.getFlatRateShipping(subtotal);
shippingCost = shippingRates[0].rate;
}
}
@ -666,7 +693,7 @@ module.exports = (pool, query, authMiddleware) => {
);
}
// If a shipping method was selected, save it with the order
// If we have shipping details, save them with the order
if (shippingMethod && shippingMethod.id) {
const shippingInfo = shipmentData || {
method_id: shippingMethod.id,
@ -683,6 +710,12 @@ module.exports = (pool, query, authMiddleware) => {
);
}
// Clear the temporary shipment ID from cart metadata
await client.query(
`UPDATE carts SET metadata = metadata - 'temp_shipment_id' WHERE id = $1`,
[cartId]
);
await client.query('COMMIT');
// Send back cart items for Stripe checkout

View file

@ -6,17 +6,20 @@ const config = require('../config');
*/
const shippingService = {
/**
* Create a shipment in EasyPost and get available rates
* Create a shipment in EasyPost and get available rates with proper IDs
* @param {Object} addressFrom - Shipping origin address
* @param {Object} addressTo - Customer shipping address
* @param {Object} parcelDetails - Package dimensions and weight
* @returns {Promise<Array>} Array of available shipping rates
* @returns {Promise<Object>} Object containing shipment details and available rates
*/
async getShippingRates(addressFrom, addressTo, parcelDetails) {
// If EasyPost is not enabled, return flat rate shipping
if (!config.shipping.easypostEnabled || !config.shipping.easypostApiKey) {
console.log("EASY POST NOT CONFIGURED ", !config.shipping.easypostEnabled, !config.shipping.easypostApiKey)
return this.getFlatRateShipping(parcelDetails.order_total);
return {
shipment_id: null,
rates: this.getFlatRateShipping(parcelDetails.order_total)
};
}
try {
@ -26,16 +29,10 @@ const shippingService = {
// Format parcel for EasyPost
const parcel = this.formatParcel(parcelDetails);
console.log("EasyPost shipment request", JSON.stringify({
shipment: {
from_address: fromAddress,
to_address: toAddress,
parcel: parcel
}
}, null , 4))
// Create shipment via EasyPost API
// Create shipment first to get proper rate IDs (using v2 API)
const response = await axios.post(
'https://api.easypost.com/beta/rates',
'https://api.easypost.com/v2/shipments',
{
shipment: {
from_address: fromAddress,
@ -53,74 +50,48 @@ const shippingService = {
}
}
);
// console.log("EasyPost shipment response", response)
// Process and filter rates
return this.processShippingRates(response.data.rates, parcelDetails.order_total);
const shipment = response.data;
console.log("Shipment created successfully:", shipment.id);
// Process and filter rates from the shipment object
const formattedRates = this.processShippingRates(shipment.rates, parcelDetails.order_total);
// Return both the shipment ID and the rates
return {
shipment_id: shipment.id,
rates: formattedRates
};
} catch (error) {
console.error('EasyPost API error:', error.response?.data || error.message);
// Fallback to flat rate if API fails
return this.getFlatRateShipping(parcelDetails.order_total);
return {
shipment_id: null,
rates: this.getFlatRateShipping(parcelDetails.order_total)
};
}
},
/**
* Create an actual shipment in EasyPost based on selected rate
* @param {Object} addressFrom - Shipping origin address
* @param {Object} addressTo - Customer shipping address
* @param {Object} parcelDetails - Package dimensions and weight
* @param {string} selectedRateId - The ID of the selected rate
* @returns {Promise<Object>} Created shipment details
* Purchase a selected rate for an existing shipment
* @param {string} shipmentId - The EasyPost shipment ID
* @param {string} rateId - The ID of the selected rate
* @returns {Promise<Object>} Purchased shipment details
*/
async createShipment(addressFrom, addressTo, parcelDetails, selectedRateId) {
// If EasyPost is not enabled, return a simulated shipment
if (!config.shipping.easypostEnabled || !config.shipping.easypostApiKey) {
console.log("EasyPost not configured for shipment creation");
return this.createSimulatedShipment(selectedRateId);
async purchaseShipment(shipmentId, rateId) {
// If EasyPost is not enabled or we don't have a shipment ID, return a simulated shipment
if (!config.shipping.easypostEnabled || !config.shipping.easypostApiKey || !shipmentId) {
console.log("Cannot purchase shipment - EasyPost not configured or no shipment ID");
return this.createSimulatedShipment(rateId);
}
try {
// Format addresses for EasyPost
const fromAddress = this.formatAddress(addressFrom || config.shipping.originAddress);
const toAddress = this.formatAddress(addressTo);
// Format parcel for EasyPost
const parcel = this.formatParcel(parcelDetails);
// Step 1: Create a shipment
const shipmentResponse = await axios.post(
'https://api.easypost.com/v2/shipments',
{
shipment: {
from_address: fromAddress,
to_address: toAddress,
parcel: parcel,
options: {
label_format: 'PDF',
label_size: '4x6'
}
}
},
{
auth: {
username: config.shipping.easypostApiKey,
password: ''
},
headers: {
'Content-Type': 'application/json'
}
}
);
const shipment = shipmentResponse.data;
console.log("Shipment created successfully:", shipment.id);
// Step 2: Buy the selected rate
// Buy the selected rate
const buyResponse = await axios.post(
`https://api.easypost.com/v2/shipments/${shipment.id}/buy`,
`https://api.easypost.com/v2/shipments/${shipmentId}/buy`,
{
rate: {
id: selectedRateId
id: rateId
}
},
{
@ -156,19 +127,19 @@ const shippingService = {
status: purchasedShipment.status
};
} catch (error) {
console.error('EasyPost API error during shipment creation:', error.response?.data || error.message);
console.error('EasyPost API error during purchasing shipment:', error.response?.data || error.message);
// Return a fallback simulated shipment
return this.createSimulatedShipment(selectedRateId);
return this.createSimulatedShipment(rateId);
}
},
/**
* Create a simulated shipment when EasyPost is not available
* @param {string} selectedRateId - The ID of the selected rate
* @param {string} rateId - The ID of the selected rate
* @returns {Object} Simulated shipment details
*/
createSimulatedShipment(selectedRateId) {
createSimulatedShipment(rateId) {
// Generate a random tracking number
const trackingNumber = `SIMSHIP${Math.floor(Math.random() * 1000000000)}`;
@ -178,15 +149,15 @@ const shippingService = {
tracking_code: trackingNumber,
label_url: null,
selected_rate: {
id: selectedRateId,
carrier: selectedRateId.includes('flat') ? 'Standard' : 'Simulated',
service: selectedRateId.includes('flat') ? 'Flat Rate Shipping' : 'Standard Service',
rate: selectedRateId.includes('free') ? 0 : config.shipping.flatRate,
id: rateId,
carrier: rateId.includes('flat') ? 'Standard' : 'Simulated',
service: rateId.includes('flat') ? 'Flat Rate Shipping' : 'Standard Service',
rate: rateId.includes('free') ? 0 : config.shipping.flatRate,
delivery_days: '5-7',
delivery_date: null
},
carrier: selectedRateId.includes('flat') ? 'Standard' : 'Simulated',
service: selectedRateId.includes('flat') ? 'Flat Rate Shipping' : 'Standard Service',
carrier: rateId.includes('flat') ? 'Standard' : 'Simulated',
service: rateId.includes('flat') ? 'Flat Rate Shipping' : 'Standard Service',
created_at: new Date().toISOString(),
status: 'simulated'
};
@ -242,7 +213,6 @@ const shippingService = {
* @returns {Array} Processed shipping rates
*/
processShippingRates(rates, orderTotal) {
console.log("processShippingRates", rates, orderTotal, config.shipping.carriersAllowed)
if (!rates || !Array.isArray(rates)) {
return this.getFlatRateShipping(orderTotal);
}
@ -258,31 +228,31 @@ const shippingService = {
return this.getFlatRateShipping(orderTotal);
}
// Format rates to standardized format
// Format rates to standardized format - now including the rate ID
const formattedRates = filteredRates.map(rate => ({
id: rate.id,
carrier: rate.carrier,
service: rate.service,
rate: parseFloat(rate.rate),
currency: rate.currency,
delivery_days: rate.delivery_days || 'Unknown',
delivery_days: rate.est_delivery_days || rate.delivery_days || 'Unknown',
delivery_date: rate.delivery_date || null,
delivery_time: rate.est_delivery_time || null
}));
// // Check if free shipping applies
// if (orderTotal >= config.shipping.freeThreshold) {
// formattedRates.push({
// id: 'free-shipping',
// carrier: 'FREE',
// service: 'Standard Shipping',
// rate: 0,
// currency: 'USD',
// delivery_days: '5-7',
// delivery_date: null,
// delivery_time: null
// });
// }
// Check if free shipping applies
if (orderTotal >= config.shipping.freeThreshold) {
formattedRates.push({
id: 'free-shipping',
carrier: 'FREE',
service: 'Standard Shipping',
rate: 0,
currency: 'USD',
delivery_days: '5-7',
delivery_date: null,
delivery_time: null
});
}
return formattedRates;
},

View file

@ -0,0 +1 @@
ALTER TABLE carts ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb;

View file

@ -26,7 +26,6 @@ import { useNavigate, Link as RouterLink } from 'react-router-dom';
import { useAuth, useCart } from '../hooks/reduxHooks';
import { useCheckout } from '../hooks/apiHooks';
import { useStripe, StripeElementsProvider } from '../context/StripeContext';
import StripePaymentForm from '../components/StripePaymentForm';
import apiClient from '../services/api';
// Checkout steps
@ -51,6 +50,7 @@ const CheckoutPage = () => {
const [shippingRates, setShippingRates] = useState([]);
const [selectedShippingMethod, setSelectedShippingMethod] = useState(null);
const [shippingCost, setShippingCost] = useState(0);
const [shipmentId, setShipmentId] = useState(null);
// State for form data
const [formData, setFormData] = useState({
@ -72,6 +72,11 @@ const CheckoutPage = () => {
...formData,
[name]: name === 'saveAddress' ? checked : value,
});
// Clear validation error when field is edited
if (error) {
setError(null);
}
};
// Handle next step
@ -87,8 +92,8 @@ const CheckoutPage = () => {
return;
}
// If valid, fetch shipping rates
fetchShippingRates();
return
}
// If on shipping method step, validate selection
@ -162,6 +167,12 @@ const CheckoutPage = () => {
if (response.data.rates && response.data.rates.length > 0) {
setShippingRates(response.data.rates);
// Store shipment_id if provided (needed for actual shipment creation)
if (response.data.shipment_id) {
setShipmentId(response.data.shipment_id);
}
// Default to lowest cost option
const lowestCostOption = response.data.rates.reduce(
(lowest, current) => current.rate < lowest.rate ? current : lowest,
@ -172,9 +183,13 @@ const CheckoutPage = () => {
} else {
setShippingRates([]);
setSelectedShippingMethod(null);
setShipmentId(null);
setShippingCost(0);
setError('No shipping options available for this address');
}
// Move to next step
setActiveStep((prevStep) => prevStep + 1);
} catch (error) {
console.error('Error fetching shipping rates:', error);
setError('Failed to retrieve shipping options. Please try again.');
@ -257,6 +272,7 @@ const CheckoutPage = () => {
}
};
// Redirect to Stripe checkout when the URL is available
useEffect(() => {
if (checkoutUrl) {