Skip to main content

Overview

Torn implements a comprehensive reverse logistics system that handles product returns, generates Chilean tax credit notes (Nota de Crédito), and manages financial reconciliation. Every return is atomically processed to maintain data integrity.
Returns in Torn generate official SII tax documents (DTE type 61 for invoices, 111 for receipts) and are fully traceable to the original sale.

Return Flow Architecture

The return process mirrors sales but in reverse:
1. Validate original sale exists
2. Validate products and quantities
3. Restock inventory (create ENTRADA movements)
4. Calculate refund amounts (use original prices)
5. Assign fiscal folio for credit note
6. Generate reference to original document
7. Create credit note (DTE 61/111)
8. Process refund (cash or credit balance)
9. COMMIT or ROLLBACK
Source: /app/routers/sales.py:318-473

Creating a Return

1

Locate Original Sale

Identify the sale to be returned by its ID:
# Get sale details
curl -X GET https://api.torn.cl/sales/?skip=0&limit=50 \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: 5"
Note the id and original tipo_dte of the sale to return.
2

Submit Return Request

Create the return with products and reason:
curl -X POST https://api.torn.cl/sales/return \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: 5" \
  -H "Content-Type: application/json" \
  -d '{
    "original_sale_id": 1523,
    "tipo_dte": 61,
    "sii_reason_code": 1,
    "reason": "Producto defectuoso",
    "items": [
      {
        "product_id": 15,
        "cantidad": 2
      }
    ],
    "return_method_id": 1
  }'
Parameters:
  • original_sale_id: ID of the sale being returned
  • tipo_dte: Credit note type (61 for invoices, 111 for receipts)
  • sii_reason_code: SII-defined reason code (see below)
  • reason: Free-text explanation
  • items: Products and quantities to return
  • return_method_id: Payment method for refund (1 = cash, 5 = internal credit)
3

Receive Credit Note

Response:
{
  "id": 1524,
  "folio": 45,
  "tipo_dte": 61,
  "fecha_emision": "2026-03-08T15:30:00Z",
  "monto_neto": 14285.71,
  "iva": 2714.29,
  "monto_total": 17000.00,
  "descripcion": "Ajuste Venta #1045: Producto defectuoso",
  "related_sale_id": 1523,
  "referencias": [
    {
      "tipo_documento": "33",
      "folio": "1045",
      "fecha": "2026-03-08",
      "sii_reason_code": 1
    }
  ],
  "customer": {
    "rut": "12345678-9",
    "razon_social": "Juan Pérez"
  },
  "details": [
    {
      "product_id": 15,
      "cantidad": 2,
      "precio_unitario": 7142.86,
      "subtotal": 14285.71,
      "product": {
        "nombre": "Martillo Carpintero",
        "codigo_interno": "MART-001"
      }
    }
  ]
}
4

Verify Stock Restoration

The returned products are automatically added back to inventory:
curl -X GET https://api.torn.cl/inventory/ \
  -H "Authorization: Bearer YOUR_TOKEN"
The stock_actual for product #15 should have increased by 2.

Credit Note Types (DTE)

Original DTEReturn DTEDescription
33 (Factura)61 (Nota de Crédito)Standard invoice credit note
34 (Factura Exenta)61 (Nota de Crédito)Tax-exempt invoice credit note
39 (Boleta)111 (NC Electrónica)Receipt credit note
Any56 (Nota de Débito)Increase amount (not returns)
Source: Referenced in /app/routers/sales.py:408-410
Always use the correct credit note type matching the original document. Mixing types will cause SII validation failures.

SII Reason Codes

Chilean tax law requires specifying why a credit note is issued:
CodeReasonWhen to Use
1Anula documento de referenciaTotal cancellation
2Corrige montoPrice correction
3Corrige textoInformational correction
4Devuelve productosProduct return (most common)
Source: Chilean SII specifications, implemented in /app/routers/sales.py:426
Use code 4 (Devuelve productos) for standard returns, and code 1 for voiding entire sales.

Inventory Restocking

Returned products are automatically restocked:
if product.controla_stock:
    # Increment stock
    product.stock_actual += item.cantidad
    
    # Create audit trail
    movement = StockMovement(
        product_id=product.id,
        user_id=user_id,
        tipo="ENTRADA",
        motivo="DEVOLUCION",
        cantidad=item.cantidad,
        description=f"Devolución venta f.{original_sale.folio}: {return_in.reason}"
    )
    stock_movements.append(movement)
Source: /app/routers/sales.py:370-381 This creates an immutable StockMovement record with:
  • tipo = "ENTRADA" (stock increase)
  • motivo = "DEVOLUCION" (return reason)
  • Link to the credit note via sale_id

Price Calculation for Returns

Returns use the historical price from the original sale, not current catalog prices:
# Find original sale detail
original_detail = db.query(SaleDetail).filter(
    SaleDetail.sale_id == original_sale.id,
    SaleDetail.product_id == product.id
).first()

if original_detail:
    precio_unitario = original_detail.precio_unitario
else:
    # Fallback to current price if original not found
    precio_unitario = product.precio_neto
Source: /app/routers/sales.py:386-391
This ensures refund amounts match what the customer actually paid, even if prices have changed since the sale.

Refund Methods

Cash Refund

Return money directly to the customer:
{
  "return_method_id": 1  // EFECTIVO
}
This creates a SalePayment record linked to the credit note. The cash should be taken from the current cash session.
Ensure the cashier has an open cash session with sufficient funds to provide the refund. Consider requiring manager approval for large cash refunds.

Credit to Customer Account

Reduce the customer’s outstanding balance:
{
  "return_method_id": 5  // CREDITO_INTERNO
}
The system automatically:
if method.code == "CREDITO_INTERNO":
    customer = db.query(Customer).get(original_sale.customer_id)
    customer.current_balance -= total  # Reduce debt
    db.add(customer)
Source: /app/routers/sales.py:454-457
Use internal credit for returns when:
  • Customer wants store credit
  • Cash isn’t immediately available
  • Customer has existing debt to offset

Card Refund (Future Enhancement)

Current implementation doesn’t handle card refunds directly. For card payments:
  1. Process return with CREDITO_INTERNO
  2. Manually process card reversal through payment processor
  3. Update customer balance if needed
Recommended Enhancement: Add integration with payment gateway APIs:
if method.code == "CREDITO":
    # Find original card payment
    original_payment = db.query(SalePayment).filter(
        SalePayment.sale_id == original_sale.id,
        SalePayment.payment_method.code == "CREDITO"
    ).first()
    
    # Call payment processor API
    refund_result = payment_gateway.refund(
        transaction_id=original_payment.transaction_code,
        amount=total
    )

Document References

Credit notes automatically reference the original document:
referencias_json = [{
    "tipo_documento": str(original_sale.tipo_dte),
    "folio": str(original_sale.folio),
    "fecha": original_sale.fecha_emision.strftime("%Y-%m-%d"),
    "sii_reason_code": return_in.sii_reason_code
}]
Source: /app/routers/sales.py:422-427 This creates a formal link required by Chilean tax law, appearing in the DTE XML:
<Referencia>
  <NroLinRef>1</NroLinRef>
  <TpoDocRef>33</TpoDocRef>
  <FolioRef>1045</FolioRef>
  <FchRef>2026-03-08</FchRef>
  <CodRef>1</CodRef>
</Referencia>

Partial Returns

Return only some items from a multi-product sale:
curl -X POST https://api.torn.cl/sales/return \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "original_sale_id": 1523,
    "tipo_dte": 61,
    "sii_reason_code": 4,
    "reason": "Cliente devuelve solo el taladro",
    "items": [
      {
        "product_id": 42,
        "cantidad": 1
      }
    ],
    "return_method_id": 1
  }'
This returns only product #42, leaving other items from the sale untouched.
There’s no validation preventing returning more quantity than originally sold. Consider adding this validation in production:
if item.cantidad > original_detail.cantidad:
    raise HTTPException(400, detail="Cannot return more than sold")

Viewing Return History

Query all credit notes:
curl -X GET "https://api.torn.cl/sales/?skip=0&limit=50" \
  -H "Authorization: Bearer YOUR_TOKEN"
Filter by tipo_dte in your application:
// Filter for credit notes
const creditNotes = sales.filter(sale => 
  sale.tipo_dte === 61 || sale.tipo_dte === 111
);

// Find returns for specific original sale
const returns = sales.filter(sale => 
  sale.related_sale_id === 1523
);

Return Analytics

Return Rate by Product

SELECT 
  p.codigo_interno,
  p.nombre,
  SUM(CASE WHEN s.tipo_dte IN (33, 39) THEN sd.cantidad ELSE 0 END) AS vendido,
  SUM(CASE WHEN s.tipo_dte IN (61, 111) THEN sd.cantidad ELSE 0 END) AS devuelto,
  ROUND(
    100.0 * SUM(CASE WHEN s.tipo_dte IN (61, 111) THEN sd.cantidad ELSE 0 END) /
    NULLIF(SUM(CASE WHEN s.tipo_dte IN (33, 39) THEN sd.cantidad ELSE 0 END), 0),
    2
  ) AS tasa_devolucion_pct
FROM products p
JOIN sale_details sd ON sd.product_id = p.id
JOIN sales s ON s.id = sd.sale_id
WHERE s.fecha_emision >= NOW() - INTERVAL '90 days'
GROUP BY p.id
HAVING SUM(CASE WHEN s.tipo_dte IN (33, 39) THEN sd.cantidad ELSE 0 END) > 0
ORDER BY tasa_devolucion_pct DESC;

Return Reasons Analysis

SELECT 
  s.descripcion,
  COUNT(*) AS cantidad_devoluciones,
  SUM(s.monto_total) AS monto_total_devuelto
FROM sales s
WHERE s.tipo_dte IN (61, 111)
  AND s.fecha_emision >= NOW() - INTERVAL '30 days'
GROUP BY s.descripcion
ORDER BY cantidad_devoluciones DESC;

Customer Return Frequency

SELECT 
  c.rut,
  c.razon_social,
  COUNT(*) AS num_devoluciones,
  SUM(s.monto_total) AS total_devuelto
FROM customers c
JOIN sales s ON s.customer_id = c.id
WHERE s.tipo_dte IN (61, 111)
  AND s.fecha_emision >= NOW() - INTERVAL '180 days'
GROUP BY c.id
HAVING COUNT(*) > 3
ORDER BY num_devoluciones DESC;

Permission Requirements

Returns require the can_perform_returns permission:
@router.post("/sales/return")
def create_return(
    return_in: ReturnCreate,
    current_user: User = Depends(get_current_local_user)
):
    if not current_user.role_obj.can_perform_returns:
        raise HTTPException(403, detail="No autorizado para devoluciones")
By default:
  • ADMINISTRADOR: ✅ Can process returns
  • VENDEDOR: ❌ Cannot process returns
  • BODEGUERO: ✅ Can process returns
See the User Roles Guide to modify permissions.
Restrict return permissions to trusted staff. Returns involve inventory changes, cash movements, and tax document generation—all high-risk operations.

Return Policies

Suggested Return Window

Implement time-based return eligibility:
from datetime import timedelta

days_since_sale = (datetime.now() - original_sale.fecha_emision).days

if days_since_sale > 30:
    raise HTTPException(
        400, 
        detail=f"Devolución fuera de plazo (30 días). Venta realizada hace {days_since_sale} días."
    )

Condition Requirements

Add business logic for return conditions:
# Example: Require manager approval for returns > $50,000
if total > 50000 and not current_user.is_manager:
    raise HTTPException(
        403,
        detail="Devoluciones sobre $50.000 requieren autorización del gerente"
    )

Receipt Requirement

Validate the original sale was to this customer:
if original_sale.customer_id != request.customer_id:
    raise HTTPException(
        400,
        detail="La venta original pertenece a otro cliente"
    )

Exchange vs. Return

For product exchanges (not pure refunds), process as two transactions:
1

Process the Return

curl -X POST https://api.torn.cl/sales/return \
  -d '{
    "original_sale_id": 1523,
    "items": [{"product_id": 15, "cantidad": 1}],
    "return_method_id": 5  // Credit to account
  }'
Customer now has store credit.
2

Process New Sale

curl -X POST https://api.torn.cl/sales/ \
  -d '{
    "rut_cliente": "12345678-9",
    "items": [{"product_id": 28, "cantidad": 1}],  // Different product
    "payments": [{"payment_method_id": 5, "amount": 8500}]  // Use credit
  }'
The customer’s credit balance is used for the new purchase.
This two-transaction approach maintains clean audit trails and properly handles price differences between exchanged products.

Voiding Sales vs. Returns

Void (Same Day)

For mistakes caught immediately:
  • Use reason code 1 (Anula documento de referencia)
  • Return all items
  • Issue full refund
{
  "sii_reason_code": 1,
  "reason": "Error en emisión - venta incorrecta"
}

Return (After Delivery)

For legitimate product returns:
  • Use reason code 4 (Devuelve productos)
  • May be partial
  • Customer keeps original invoice and receives credit note
{
  "sii_reason_code": 4,
  "reason": "Cliente no satisfecho con el producto"
}

Best Practices

Verify Product Condition

Before processing the return:
  1. Physically inspect returned products
  2. Verify serial numbers match (if applicable)
  3. Confirm products are resaleable
  4. Document any damage

Manager Approval Workflow

For high-value returns:
  1. Cashier initiates return request
  2. System flags for manager review
  3. Manager approves/rejects with reason
  4. Only then process the credit note
Consider adding an approval_status field to track this.

Restocking Fees

For opened/used products, deduct a restocking fee:
refund_amount = original_price * 0.85  # 15% restocking fee
Create the credit note for the reduced amount.

Document Everything

Use the reason field extensively:
  • Why is the return being made?
  • What is the product condition?
  • Was the customer satisfied with resolution?
  • Any special circumstances?
This aids dispute resolution and trend analysis.

Train Staff Thoroughly

Returns involve:
  • Customer service skills
  • Tax document regulations
  • Inventory management
  • Cash handling
  • System procedures
Ensure all staff with return permissions are properly trained.

Build docs developers (and LLMs) love