Skip to main content
POST
/
api
/
v1
/
movements
/
sale
Register Sale (EXIT Movement)
curl --request POST \
  --url https://api.example.com/api/v1/movements/sale
{
  "error": "Missing required fields"
}

Overview

This endpoint registers an EXIT movement for a product sale. The system automatically:
  • Deducts stock from the oldest batches first (FIFO)
  • Calculates the actual cost of goods sold (COGS)
  • Returns both the sale price and the cost for profit analysis

Authentication

Authorization
string
required
Bearer token with admin or gestor role

Request Body

product_id
string
required
Unique identifier of the product being sold
quantity
integer
required
Number of units to sell (must be greater than 0)
unit_price
float
Sale price per unit. If not provided, uses the product’s suggested_price
notes
string
Additional notes about the sale. Defaults to “Sale”

Response

movement_id
string
Unique identifier for the created movement
product_id
string
Product identifier
quantity
integer
Number of units sold
unit_price
float
Sale price per unit applied
total_price
float
Total revenue (quantity * unit_price)
total_cost
float
Total cost of goods sold calculated via FIFO

Example Request

curl -X POST https://api.example.com/api/v1/movements/sale \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "product_id": "550e8400-e29b-41d4-a716-446655440000",
    "quantity": 10,
    "unit_price": 29.99,
    "notes": "Sold to customer ABC"
  }'

Example Response

{
  "movement_id": "7f3d9c8e-1234-5678-90ab-cdef12345678",
  "product_id": "550e8400-e29b-41d4-a716-446655440000",
  "quantity": 10,
  "unit_price": 29.99,
  "total_price": 299.90,
  "total_cost": 180.50
}
In this example:
  • Revenue: 299.90(10units×299.90 (10 units × 29.99)
  • Cost: $180.50 (FIFO calculated from batches)
  • Gross Profit: 119.40(119.40 (299.90 - $180.50)

Error Responses

{
  "error": "Missing required fields"
}

Implementation Details

From backend/Product/Adapters/movement_controller.py:11-42:
@router.route('/sale', methods=['POST'])
@require_role('admin', 'gestor')
def register_sale():
    data = request.get_json()
    if not data or not data.get('product_id') or not data.get('quantity'):
        return jsonify({"error": "Missing required fields"}), 400

    db = next(get_db())
    stock_svc = StockService(db)
    
    try:
        unit_price = data.get('unit_price')
        if unit_price is not None:
            unit_price = float(unit_price)

        mov, cost = stock_svc.register_exit(
            product_id=data.get('product_id'),
            quantity=int(data.get('quantity')),
            unit_price=unit_price,
            notes=data.get('notes', 'Sale')
        )
        
        return jsonify({
            "movement_id": mov.id,
            "product_id": mov.product_id,
            "quantity": mov.quantity,
            "unit_price": mov.unit_price,
            "total_price": mov.total_price,
            "total_cost": cost
        }), 200
    except Exception as e:
        return jsonify({"detail": str(e)}), 400

FIFO Cost Calculation

The EXIT movement uses FIFO logic from backend/Product/Domain/stock_service.py:57-109:
  1. Retrieves all active batches sorted by expiration date, then purchase date
  2. Deducts quantity from the oldest batches first
  3. Calculates total cost by summing: quantity_from_batch * batch.unit_cost
  4. Updates batch quantities and creates the movement record
  5. Returns both the movement object and the calculated COGS

See Also

Build docs developers (and LLMs) love