diff --git a/backend/src/routes/cart.js b/backend/src/routes/cart.js index b00818b..b8f3f96 100644 --- a/backend/src/routes/cart.js +++ b/backend/src/routes/cart.js @@ -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 diff --git a/backend/src/services/shippingService.js b/backend/src/services/shippingService.js index 5bafc08..9ffcc95 100644 --- a/backend/src/services/shippingService.js +++ b/backend/src/services/shippingService.js @@ -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 of available shipping rates + * @returns {Promise} 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} 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} 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; }, diff --git a/db/init/13-cart-metadata.sql b/db/init/13-cart-metadata.sql new file mode 100644 index 0000000..d009424 --- /dev/null +++ b/db/init/13-cart-metadata.sql @@ -0,0 +1 @@ +ALTER TABLE carts ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb; \ No newline at end of file diff --git a/frontend/src/pages/CheckoutPage.jsx b/frontend/src/pages/CheckoutPage.jsx index 37503fa..c5c68ed 100644 --- a/frontend/src/pages/CheckoutPage.jsx +++ b/frontend/src/pages/CheckoutPage.jsx @@ -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) {