# 05 · Esquema Backend

**Proyecto:** Sistema Boutique (POS + Tienda Online)
**Base de datos:** PostgreSQL 15+
**Versión:** 1.0

Este documento define **cómo se guarda la información** y **cómo se autentican los usuarios**. Las tablas se presentan módulo por módulo, en orden de dependencia (primero las que no dependen de nadie).

---

## 1. Flujo de autenticación

Hay **dos tipos de identidad** en el sistema, y es importante no mezclarlas:

- **Usuarios del staff** (admin, gerente, vendedor) → operan el POS y el panel. Tabla `users`.
- **Clientes** (compradores online) → operan la tienda. Tabla `customers`.

Se separan porque tienen datos, permisos y ciclos de vida distintos. Un cajero no es un cliente y viceversa. (Un mismo humano podría ser ambos, pero son dos registros con dos propósitos.)

### Flujo (igual para staff y clientes, distintas tablas)

```
1. Registro/alta
   → contraseña se hashea con Argon2id ANTES de guardar
   → nunca se guarda la contraseña en texto plano

2. Login
   → el usuario envía email + contraseña
   → el backend verifica el hash con Argon2id
   → si es válido, emite:
       • ACCESS TOKEN  (JWT, vida corta ~15 min) → autoriza cada petición
       • REFRESH TOKEN (vida larga ~7 días, guardado en BD) → renueva el access
   → rate limiting: máx N intentos por IP/email para frenar fuerza bruta

3. Petición autenticada
   → el frontend manda el access token en header: Authorization: Bearer <token>
   → el backend valida firma + expiración + ROL en cada endpoint protegido
   → el backend NUNCA confía en el rol que diga el frontend; lo lee del token firmado

4. Renovación
   → cuando el access token expira, el frontend usa el refresh token
   → el refresh token es ROTATORIO: cada uso emite uno nuevo y anula el anterior
   → esto limita el daño si un refresh token se filtra

5. Logout
   → se invalida el refresh token en BD
```

El **rol** (`admin`, `manager`, `seller`) viaja dentro del JWT firmado. Cada endpoint declara qué roles lo pueden usar. Un vendedor que intente pegarle a `/reports/commissions` de otro recibe 403, aunque manipule el frontend — porque la autorización vive en el servidor.

---

## 2. Módulos y tablas

Orden de creación (respeta dependencias):

```
Módulo A · Identidad y acceso   → users, refresh_tokens, customers, customer_addresses
Módulo B · Catálogo             → categories, suppliers, products, product_variants
Módulo C · Inventario           → inventory_movements
Módulo D · Ventas (POS)         → sales, sale_items, payments
Módulo E · Pedidos (Online)     → orders, order_items, order_payments
Módulo F · Fidelización         → loyalty_accounts, loyalty_transactions
Módulo G · Comisiones           → commission_rules, seller_commissions
Módulo H · Promociones/Kits     → promotions, kits, kit_items
Módulo I · Configuración        → settings
```

---

## Módulo A · Identidad y acceso

### `users` (staff: admin, gerente, vendedor)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| full_name | text | |
| email | text UNIQUE | login |
| password_hash | text | Argon2id |
| role | enum(`admin`,`manager`,`seller`) | controla permisos |
| discount_limit_pct | numeric(5,2) | tope de descuento que puede aplicar (RB4). **Unidad: porcentaje entero** — `10.00` significa 10 %, no 0,10 %. La validación de RB4 usa esta convención. |
| is_active | boolean | baja lógica, no se borra |
| created_at / updated_at | timestamptz | |

### `refresh_tokens`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| user_id | uuid FK → users | on delete cascade |
| token_hash | text | se guarda hasheado, no en claro |
| expires_at | timestamptz | |
| revoked_at | timestamptz null | |
| created_at | timestamptz | |

### `customers` (compradores online)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| full_name | text | |
| email | text UNIQUE | login |
| phone | text | celular (clave para fidelización en tienda física) |
| password_hash | text null | null si se registró en caja sin clave |
| created_at / updated_at | timestamptz | |

### `customer_addresses` (múltiples direcciones por cliente)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| customer_id | uuid FK → customers | on delete cascade |
| label | text | "Casa", "Trabajo" |
| address_line | text | |
| city | text | |
| department | text | departamento (Colombia) |
| phone | text | |
| is_default | boolean | |

---

## Módulo B · Catálogo

**Concepto clave — Producto vs. Variante:**
Un **producto** es "Blusa Manga Larga". Sus **variantes** son "Blusa · M · Azul", "Blusa · L · Azul", etc. **El stock y el código de barras viven en la VARIANTE, no en el producto**, porque lo que se vende es una talla y color concretos. Confundir esto es el error estructural más común en sistemas de ropa.

Analogía: el producto es la "canción"; las variantes son las "versiones grabadas" (en vivo, en estudio, acústica). Vendes una grabación concreta, no la canción abstracta.

### `categories` (tipo de prenda)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| name | text UNIQUE | "Blusas", "Jeans", "Vestidos" |
| is_active | boolean | |

### `suppliers` (proveedores)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| name | text | |
| contact_name | text | |
| phone | text | |
| email | text null | |
| notes | text null | |

### `products`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| category_id | uuid FK → categories | on delete restrict |
| supplier_id | uuid FK → suppliers null | on delete set null |
| name | text | |
| description | text null | |
| reference | text UNIQUE | referencia interna |
| base_price | numeric(12,0) | precio de venta COP (entero) |
| cost_price | numeric(12,0) | costo (solo admin/gerente lo ven — RB6) |
| is_active | boolean | |
| created_at / updated_at | timestamptz | |

### `product_images` (galería de fotos por producto)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| product_id | uuid FK → products | on delete cascade |
| file_path | text | ruta relativa al directorio de uploads (`/uploads/products/`), sin dominio |
| is_primary | boolean | imagen principal que aparece en tarjeta de catálogo |
| sort_order | integer | orden en la galería de la ficha de producto |
| created_at | timestamptz | |

**Almacenamiento:** las imágenes se guardan en el disco del VPS bajo `/var/www/boutique/uploads/products/` y Apache las sirve como archivos estáticos (alias en el vhost). No se usa ningún servicio externo (sin S3 ni Cloudinary). El campo `file_path` guarda la ruta relativa (sin dominio) para que el mismo registro funcione en ambos ambientes (dev y prod).

---

### `product_variants`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| product_id | uuid FK → products | on delete cascade |
| size | text | "S","M","L","32","34" |
| color | text | "Azul","Negro" |
| barcode | text UNIQUE | lo que lee el escáner |
| stock | integer | **≥ 0 SIEMPRE** (CHECK stock >= 0) |
| min_stock | integer | umbral de aviso (RB inventario) |
| price_override | numeric(12,0) null | si esta variante cuesta distinto |
| is_active | boolean | |
| | | UNIQUE(product_id, size, color) — no duplicar variantes |

**Índices:** `barcode` (búsqueda por escaneo), `product_id`, `reference`.
**Constraint crítico:** `CHECK (stock >= 0)` — la base de datos misma impide stock negativo. Es la última línea de defensa contra sobreventa.

---

## Módulo C · Inventario (kardex)

### `inventory_movements` (historial de TODO movimiento)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| variant_id | uuid FK → product_variants | on delete restrict |
| type | enum(`purchase`,`sale`,`adjustment`,`return`) | entrada/salida/ajuste/devolución |
| quantity | integer | + entra, − sale |
| unit_cost | numeric(12,0) null | costo en compras (para "total invertido") |
| reference_id | uuid null | id de la venta/pedido que lo originó |
| user_id | uuid FK → users null | quién lo hizo |
| note | text null | |
| created_at | timestamptz | |

Esta tabla es el **libro contable del stock**: cada cambio de `product_variants.stock` deja aquí un renglón. Nunca se modifica el stock sin escribir un movimiento. Así el kardex siempre cuadra con el stock actual.

---

## Módulo D · Ventas (POS)

### `sales`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| seller_id | uuid FK → users | vendedor (para comisión — RB5) |
| customer_id | uuid FK → customers null | opcional |
| subtotal | numeric(12,0) | antes de descuento e IVA |
| discount_total | numeric(12,0) | descuento aplicado |
| tax_total | numeric(12,0) | IVA (19% configurable) |
| total | numeric(12,0) | a cobrar |
| status | enum(`completed`,`voided`) | |
| created_at | timestamptz | |

### `sale_items`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| sale_id | uuid FK → sales | on delete cascade |
| variant_id | uuid FK → product_variants | on delete restrict |
| quantity | integer | |
| unit_price | numeric(12,0) | precio al momento de la venta (congelado) |
| discount | numeric(12,0) | descuento de la línea |
| line_total | numeric(12,0) | |

> `unit_price` se **congela** en la venta: si mañana sube el precio del producto, las ventas viejas conservan su precio real. Nunca se lee el precio actual del producto para una venta pasada.

### `payments` (una venta puede tener pago mixto)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| sale_id | uuid FK → sales | on delete cascade |
| method | enum(`cash`,`card`,`transfer`) | |
| amount | numeric(12,0) | |
| received | numeric(12,0) null | efectivo recibido (para vuelto) |
| created_at | timestamptz | |

---

## Módulo E · Pedidos (Tienda Online)

### `orders`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| customer_id | uuid FK → customers | on delete restrict |
| address_id | uuid FK → customer_addresses null | null si retiro en tienda |
| fulfillment | enum(`shipping`,`pickup`) | |
| subtotal / discount_total / tax_total / total | numeric(12,0) | |
| status | enum(`pending`,`paid`,`shipped`,`delivered`,`cancelled`) | |
| created_at / updated_at | timestamptz | |

### `order_items`
Misma estructura que `sale_items` pero apuntando a `orders`. Precio congelado igual.

### `order_payments`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| order_id | uuid FK → orders | on delete cascade |
| provider | enum(`wompi`,`mercadopago`) | |
| provider_ref | text | id de transacción de la pasarela |
| amount | numeric(12,0) | |
| status | enum(`pending`,`approved`,`declined`) | |
| created_at | timestamptz | |

### `stock_reservations` (reservas temporales durante el checkout online)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| variant_id | uuid FK → product_variants | on delete cascade |
| quantity | integer | CHECK (quantity > 0) |
| customer_id | uuid FK → customers null | cliente autenticado; null si sesión anónima |
| session_id | text null | identificador de sesión anónima (si aún no existe customer_id) |
| expires_at | timestamptz | `now() + checkout.reservation_ttl_minutes` (default 15 min) |
| created_at | timestamptz | |

**Índice:** `(variant_id, expires_at)` — se consulta en cada cálculo de stock disponible.

**Regla de stock disponible:**
```
stock_disponible = product_variants.stock − Σ quantity
                   WHERE variant_id = ? AND expires_at > now()
```
El stock físico (`product_variants.stock`) **NO se modifica al crear la reserva**; solo se descuenta definitivamente en la transacción atómica al confirmar el pago. Las reservas expiradas se ignoran en el cálculo; una tarea periódica (o eliminación lazy) las limpia.

**TTL por defecto:** 15 minutos — clave `checkout.reservation_ttl_minutes` en `settings`, configurable por el dueño.

---

## Módulo F · Fidelización

### `loyalty_accounts`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| customer_id | uuid FK → customers UNIQUE | on delete cascade |
| points_balance | integer | saldo actual |
| tier | enum(`standard`,`silver`,`gold`) | nivel de beneficios |

### `loyalty_transactions`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| account_id | uuid FK → loyalty_accounts | on delete cascade |
| type | enum(`earn`,`redeem`) | |
| points | integer | |
| source_type | enum(`sale`,`order`) | de dónde vino |
| source_id | uuid | |
| created_at | timestamptz | |

> **Mecánica configurable (RB10):** la tasa de puntos por COP gastado (`loyalty.points_per_cop`), los umbrales de subida de tier (`loyalty.tier_thresholds`) y los beneficios por nivel (`loyalty.tier_benefits`) se leen en tiempo de ejecución desde la tabla `settings`. Ningún valor numérico está incrustado en el código. El dueño los configura desde el panel de administración.

---

## Módulo G · Comisiones

### `commission_rules`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| scope | enum(`global`,`product`) | estándar o diferenciada (RB comisión) |
| product_id | uuid FK → products null | solo si scope=product |
| percentage | numeric(5,2) | % de comisión |
| is_active | boolean | |

### `seller_commissions` (comisión calculada por venta)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| seller_id | uuid FK → users | |
| sale_id | uuid FK → sales | on delete cascade |
| base_amount | numeric(12,0) | sobre qué se calculó |
| percentage | numeric(5,2) | |
| commission_amount | numeric(12,0) | resultado |
| created_at | timestamptz | |

> **Regla de negocio (RB8):** `seller_commissions` aplica **exclusivamente a ventas POS** (referenciadas por `sale_id`). Los pedidos de la tienda online (`orders`) **no generan comisión de vendedor**. Esta es una decisión de negocio explícita, no una omisión del modelo. No existe FK a `orders` en esta tabla.

---

## Módulo H · Promociones y Kits

### `promotions`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| name | text | |
| discount_type | enum(`percentage`,`fixed`) | |
| discount_value | numeric(12,0) | |
| starts_at / ends_at | timestamptz | vigencia por fecha |
| is_active | boolean | |

### `promotion_targets` (enlace promoción → categoría o producto)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| promotion_id | uuid FK → promotions | on delete cascade |
| target_type | enum(`category`,`product`) | ámbito de aplicación |
| target_id | uuid | FK a `categories.id` o `products.id` según `target_type` (no FK declarada; se valida en aplicación) |

> **Regla de negocio (RB9):** una promoción aplica a todos los productos de una categoría (`target_type = category`) o a un producto específico (`target_type = product`). Si varias promociones activas aplican al mismo ítem, se usa la de **mayor descuento**. Las promociones **no se acumulan**.

### `kits` (combo a precio conjunto)
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| name | text | |
| kit_price | numeric(12,0) | precio del combo |
| is_active | boolean | |

### `kit_items`
| Columna | Tipo | Notas |
|---------|------|-------|
| id | uuid PK | |
| kit_id | uuid FK → kits | on delete cascade |
| variant_id | uuid FK → product_variants | on delete restrict |
| quantity | integer | |

---

## Módulo I · Configuración

### `settings` (clave-valor tipado)
| Columna | Tipo | Notas |
|---------|------|-------|
| key | text PK | ver claves definidas abajo |
| value | jsonb | valor tipado |
| updated_at | timestamptz | |

**Claves de configuración definidas:**

| Clave | Tipo valor | Descripción | Default |
|-------|-----------|-------------|---------|
| `tax_rate` | number | IVA en % entero | `19` |
| `store_name` | string | Nombre de la tienda | — |
| `currency` | string | Código de moneda | `"COP"` |
| `low_stock_alert` | number | Unidades mínimas antes de alerta | — |
| `checkout.reservation_ttl_minutes` | number | Minutos que dura la reserva de stock en el checkout online | `15` |
| `loyalty.points_per_cop` | number | Puntos acumulados por cada COP gastado | configurable |
| `loyalty.tier_thresholds` | object | Puntos necesarios para subir a `silver` y `gold` | configurable |
| `loyalty.tier_benefits` | object | Beneficios concretos por tier (`standard`, `silver`, `gold`) | configurable |

Nada de estas reglas de negocio se incrusta en el código (RB3, RB10). El dueño las configura desde el panel de administración.

---

## 3. Diagrama de relaciones (resumen)

```
categories ─┐
suppliers ──┼─< products >──< product_images
            │       │
            │       └──< product_variants ──< inventory_movements
            │                    │
            │                    ├──< stock_reservations ── customers (null ok)
            │                    │
            │                    ├──< sale_items >── sales ──< payments
            │                    │                     │
            │                    │                     └──< seller_commissions >── users
            │                    │
            │                    └──< order_items >── orders ──< order_payments
            │                                           │
customers ──┼──< customer_addresses ──────────────────-┘
            └──< loyalty_accounts ──< loyalty_transactions
users ──< refresh_tokens
commission_rules
promotions ──< promotion_targets   (target_id → categories.id | products.id)
kits ──< kit_items
settings
```

Leyenda: `──<` = uno a muchos. Cada FK con su regla `on delete` definida arriba. Sin datos redundantes: el stock vive en un solo lugar (la variante), el precio de venta se congela en la línea, el kardex nunca se contradice con el stock, y las reservas no tocan el stock físico.
