diff --git a/flask/src/db.py b/flask/src/db.py index c1f056e81..bc1caadde 100644 --- a/flask/src/db.py +++ b/flask/src/db.py @@ -142,20 +142,27 @@ def get_inventory(cart): try: with sentry_sdk.start_span(op="get_inventory", description="db.connect"): connection = db.connect() - with sentry_sdk.start_span(op="get_inventory", description="db.query") as span: - inventory = connection.execute( - "SELECT * FROM inventory WHERE productId in %s" % (productIds) + + with sentry_sdk.start_span(op="get_inventory.validate_products", description="db.query") as span: + # First verify if products exist + products = connection.execute( + "SELECT id FROM products WHERE id in %s" % (productIds) ).fetchall() - span.set_data("inventory",inventory) + + if len(products) != len(productIds): + raise Exception("One or more products not found in catalog") + + # Then get inventory with proper join to ensure we get all products + with sentry_sdk.start_span(op="get_inventory.get_inventory", description="db.query") as span: + inventory = connection.execute( + """SELECT i.*, COALESCE(i.count, 0) as count + FROM products p + LEFT JOIN inventory i ON p.id = i.productId + WHERE p.id in %s""" % (productIds) + ).fetchall() + span.set_data("inventory", inventory) except BrokenPipeError as err: raise DatabaseConnectionError('get_inventory') - except Exception as err: - err_string = str(err) - if UNPACK_FROM_ERROR in err_string: - raise DatabaseConnectionError('get_inventory') - else: - raise(err) - return inventory diff --git a/flask/src/main.py b/flask/src/main.py index a4ed4a712..a2bcb01d0 100644 --- a/flask/src/main.py +++ b/flask/src/main.py @@ -165,31 +165,53 @@ def checkout(): form = order["form"] validate_inventory = True if "validate_inventory" not in order else order["validate_inventory"] == "true" - inventory = [] try: - with sentry_sdk.start_span(op="/checkout.get_inventory", description="function"): - with sentry_sdk.metrics.timing(key="checkout.get_inventory.execution_time"): - inventory = get_inventory(cart) + with sentry_sdk.start_transaction(name="checkout", op="checkout") as transaction: + inventory = [] + try: + with sentry_sdk.start_span(op="/checkout.get_inventory", description="function"): + with sentry_sdk.metrics.timing(key="checkout.get_inventory.execution_time"): + inventory = get_inventory(cart) + except Exception as err: + sentry_sdk.metrics.incr(key="checkout.failed", tags={"reason": "inventory_fetch_failed"}) + raise err + + print("> /checkout inventory", inventory) + print("> validate_inventory", validate_inventory) + + with sentry_sdk.start_span(op="process_order", description="function"): + quantities = cart['quantities'] + inventory_map = {str(item['productId']): item for item in inventory} + + # Validate all quantities against inventory + insufficient_items = [] + for product_id, requested_qty in quantities.items(): + requested_qty = int(requested_qty) + if product_id not in inventory_map: + insufficient_items.append({"id": product_id, "reason": "not_found"}) + continue + + available_qty = inventory_map[product_id]['count'] + if validate_inventory and (available_qty < requested_qty): + insufficient_items.append({ + "id": product_id, + "reason": "insufficient_stock", + "requested": requested_qty, + "available": available_qty + }) + + if insufficient_items: + sentry_sdk.metrics.incr(key="checkout.failed", tags={"reason": "insufficient_stock"}) + return jsonify({ + "error": "Inventory check failed", + "details": insufficient_items + }), 400 + + response = make_response("success") + return response except Exception as err: raise (err) - print("> /checkout inventory", inventory) - print("> validate_inventory", validate_inventory) - - with sentry_sdk.start_span(op="process_order", description="function"): - quantities = cart['quantities'] - for cartItem in quantities: - for inventoryItem in inventory: - print("> inventoryItem.count", inventoryItem['count']) - if (validate_inventory and (inventoryItem.count < quantities[cartItem] or quantities[cartItem] >= inventoryItem.count)): - sentry_sdk.metrics.incr(key="checkout.failed") - raise Exception("Not enough inventory for product") - if len(inventory) == 0 or len(quantities) == 0: - raise Exception("Not enough inventory for product") - - response = make_response("success") - return response - @app.route('/success', methods=['GET']) def success(): diff --git a/flask/src/schema.sql b/flask/src/schema.sql new file mode 100644 index 000000000..b4b57b682 --- /dev/null +++ b/flask/src/schema.sql @@ -0,0 +1,48 @@ +-- Ensure products table has proper constraints +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + price DECIMAL(10,2) NOT NULL CHECK (price >= 0), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Ensure inventory table has proper constraints and is linked to products +CREATE TABLE IF NOT EXISTS inventory ( + id SERIAL PRIMARY KEY, + productId INTEGER NOT NULL REFERENCES products(id), + count INTEGER NOT NULL DEFAULT 0 CHECK (count >= 0), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_product UNIQUE (productId) +); + +-- Create trigger to auto-create inventory records for new products +CREATE OR REPLACE FUNCTION create_inventory_for_product() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO inventory (productId, count) + VALUES (NEW.id, 0) + ON CONFLICT (productId) DO NOTHING; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Attach trigger to products table +DROP TRIGGER IF EXISTS ensure_inventory_exists ON products; +CREATE TRIGGER ensure_inventory_exists + AFTER INSERT ON products + FOR EACH ROW + EXECUTE FUNCTION create_inventory_for_product(); + +-- Create function to fix missing inventory records +CREATE OR REPLACE FUNCTION fix_missing_inventory() +RETURNS void AS $$ +BEGIN + INSERT INTO inventory (productId, count) + SELECT id, 0 + FROM products p + WHERE NOT EXISTS ( + SELECT 1 FROM inventory i WHERE i.productId = p.id + ); +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/react/src/components/Checkout.jsx b/react/src/components/Checkout.jsx index d3eaa1fca..1193893c9 100644 --- a/react/src/components/Checkout.jsx +++ b/react/src/components/Checkout.jsx @@ -10,6 +10,7 @@ import { getTag, itemsInCart } from '../utils/utils'; function Checkout({ backend, rageclick, checkout_success, cart }) { const navigate = useNavigate(); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); let initialFormValues; let se = sessionStorage.getItem('se'); const seTdaPrefixRegex = /[^-]+-tda-[^-]+-/; @@ -114,8 +115,37 @@ function Checkout({ backend, rageclick, checkout_success, cart }) { setLoading(true); try { - await checkout(cart); + const response = await fetch(backend + '/checkout?v2=true', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cart: cart, + form: form, + validate_inventory: checkout_success ? "false" : "true", + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + + if (errorData.details) { + const errorMessage = errorData.details.map(item => { + if (item.reason === 'not_found') { + return `Product ${item.id} is no longer available`; + } else if (item.reason === 'insufficient_stock') { + return `Only ${item.available} units available for product ${item.id}, but ${item.requested} were requested`; + } + return `Issue with product ${item.id}`; + }).join('\n'); + + setError(errorMessage); + throw new Error(errorMessage); + } else { + throw new Error(errorData.error || 'Checkout failed'); + } + } } catch (error) { + setError(error.message || 'An unexpected error occurred'); Sentry.captureException(error); hadError = true; } @@ -128,9 +158,19 @@ function Checkout({ backend, rageclick, checkout_success, cart }) { } }) } + + const clearError = () => { + setError(null); + }; return (
+ {error && ( +
+

{error}

+ +
+ )} {loading ? (