diff --git a/backlog/features.json b/backlog/features.json index f66a035..3ea2268 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -7168,6 +7168,158 @@ "close": true }, "completed_at": "2026-08-22T17:30:45Z" + }, + { + "id": "F-186", + "type": "feature", + "title": "POS configurable checkout, mixed payments and receipts", + "problem": "Current POS lacks touch-safe discounts, terminal restrictions, split payment confirmation, configurable methods, complete receipts and free items", + "goal": "Complete the cashier flow from item entry through mixed payment and receipt delivery", + "scope_in": [ + "8 quick products", + "touch discount button and admin disable setting", + "configurable payment methods", + "total or partial mixed payments with cash change", + "explicit sale confirmation", + "receipt print and email with fiscal data and numbering", + "clear cashier after receipt", + "free item name and price" + ], + "scope_out": [ + "Real external payment processor integrations", + "fiscal certification", + "hardware-specific printer drivers" + ], + "priority": "high", + "risk": "high", + "description": "Problem: Current POS lacks touch-safe discounts, terminal restrictions, split payment confirmation, configurable methods, complete receipts and free items. Goal: Complete the cashier flow from item entry through mixed payment and receipt delivery. Scope IN: 8 quick products, touch discount button and admin disable setting, configurable payment methods, total or partial mixed payments with cash change, explicit sale confirmation, receipt print and email with fiscal data and numbering, clear cashier after receipt, free item name and price. Scope OUT: Real external payment processor integrations, fiscal certification, hardware-specific printer drivers. Type: feature. Priority: high. Risk: high.", + "acceptance": [ + "Eight quick products can be configured and shown", + "Line discount uses a touch button and can be disabled per terminal from admin", + "Cashier supports total or partial payments across enabled configurable methods and shows remaining amount", + "Cash overpayment computes and records change", + "Sale closes only after explicit confirmation of fully allocated payments", + "Receipt contains company data date time configurable ticket number items quantities subtotals totals payment lines change and return policy", + "Receipt can be printed or emailed and cashier clears only after one delivery action", + "Free item with name and price can be added without stock mutation", + "All validation and monetary calculations are server-authoritative", + "Existing POS sales and reporting remain compatible", + "verify.sh is green" + ], + "status": "done", + "created_at": "2026-08-22", + "gates": { + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-22T20:08:08Z" + }, + { + "id": "F-187", + "type": "feature", + "title": "Admin can deactivate and delete POS cashiers", + "description": "Allow safe cashier removal from admin while preserving historical sale and session attribution.", + "priority": "high", + "risk": "med", + "status": "pending", + "created_at": "2026-08-22", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } + }, + { + "id": "F-188", + "type": "feature", + "title": "POS pending payment orders and completion states", + "description": "Allow cashier orders to remain pending with outstanding balance and ensure fully paid POS orders are COMPLETED.", + "priority": "high", + "risk": "high", + "status": "pending", + "created_at": "2026-08-22", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } + }, + { + "id": "F-189", + "type": "feature", + "title": "POS negative returns and return receipts", + "description": "Accept validated negative quantities to cancel purchases fully or partially and issue linked return receipts.", + "priority": "high", + "risk": "high", + "status": "pending", + "created_at": "2026-08-22", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } + }, + { + "id": "F-190", + "type": "fix", + "title": "Reporting updates from POS sales and returns", + "description": "Fix reporting capture and refresh so POS sales payments returns pending and completed states update reports.", + "priority": "high", + "risk": "high", + "status": "pending", + "created_at": "2026-08-22", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } + }, + { + "id": "F-191", + "type": "feature", + "title": "POS terminal and daily cash close reconciliation", + "description": "Design and implement terminal and daily cash close balancing card cash returns and pending payments.", + "priority": "high", + "risk": "high", + "status": "pending", + "created_at": "2026-08-22", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } + }, + { + "id": "F-192", + "type": "fix", + "title": "Simplify storefront net price VAT label", + "description": "Render price copy as amount sin IVA plus rate, removing the separator and duplicated IVA word.", + "priority": "med", + "risk": "low", + "status": "pending", + "created_at": "2026-08-22", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } + }, + { + "id": "F-193", + "type": "feature", + "title": "Show product expiry and shipping weight on storefront", + "description": "Display expiry and weight in frontend product views and use authoritative product weight in shipping calculations.", + "priority": "high", + "risk": "med", + "status": "pending", + "created_at": "2026-08-22", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } } ] } diff --git a/docs/pos/POS_API.md b/docs/pos/POS_API.md index 8cd985f..1a496e4 100644 --- a/docs/pos/POS_API.md +++ b/docs/pos/POS_API.md @@ -2,6 +2,8 @@ > **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md) · [`POS_DATA_MODEL.md`](./POS_DATA_MODEL.md) > **Status:** Discovery (Phase 1) +> +> **F-186 update:** the implemented mixed-payment/free-item/receipt contract is documented in [`POS_CHECKOUT.md`](./POS_CHECKOUT.md). Where this discovery document still shows the legacy one-payment request, `POS_CHECKOUT.md` takes precedence. This document describes every HTTP endpoint exposed under `/pos/*`. Auth is the same cookie-based session used by the admin app (backoffice session). Roles: `admin`, `pos_manager`, `pos_cashier`. Every request is validated through `parseJson(zodSchema, body)` and every error is `AppError`-shaped. diff --git a/docs/pos/POS_CHECKOUT.md b/docs/pos/POS_CHECKOUT.md new file mode 100644 index 0000000..775ba00 --- /dev/null +++ b/docs/pos/POS_CHECKOUT.md @@ -0,0 +1,144 @@ +# TPV — Checkout, pagos mixtos y tickets + +> Implementado en F-186. Complementa `POS_API.md` y sustituye el flujo inmediato de pago único descrito en documentos de discovery antiguos. + +## Configuración en administración + +En **Administración → TPV**, selecciona una tienda para configurar: + +### Terminal + +- Navegación táctil por categorías. +- Hasta **ocho** productos rápidos por terminal. +- **Permitir descuentos por línea**. Desactívalo en terminales de autopago. La restricción se aplica también en backend, no solo ocultando el botón. + +### Formas de pago + +Cada tienda puede activar o desactivar métodos con: + +- código estable (`cash`, `card`, `bizum`, `stripe`, `apple_pay`, etc.); +- etiqueta visible; +- tipo `cash`, `card` u `other`. + +Una etiqueta no integra automáticamente una pasarela. Stripe, Apple Pay, Bizum u otros métodos se registran manualmente hasta que exista un adaptador de proveedor. + +### Empresa y ticket + +Configura: + +- nombre o razón social; +- NIF/CIF; +- dirección, teléfono y email; +- cabecera y pie; +- prefijo, próximo número y dígitos de relleno; +- política de devolución. + +La asignación del siguiente número se bloquea dentro de la transacción de venta, evitando números duplicados entre terminales concurrentes. + +## Flujo de caja + +1. Añade productos de catálogo o un **Artículo libre** (nombre y precio positivo). +2. Aplica descuentos por línea si el terminal los permite. +3. Pulsa una forma de pago configurada. +4. En el modal elige: + - **Paga el total**: asigna todo el importe pendiente. + - **Paga una parte**: introduce un importe menor y añade después otro método. +5. En efectivo, indica lo entregado. El TPV muestra el cambio. +6. Revisa las líneas de pago, el total pagado y el importe pendiente. Se puede quitar una asignación antes de confirmar. +7. Cuando el pendiente sea cero, pulsa **Confirmar y cerrar ticket**. +8. Imprime o envía el ticket por email. La caja se limpia después de completar una de estas acciones. + +Una venta totalmente pagada queda en estado `COMPLETED`. Los pagos pendientes se implementan aparte en F-188. + +## Contrato de venta + +`POST /pos/sales` + +### Línea de stock + +```json +{ + "kind": "stock", + "variantId": "uuid", + "quantity": 2, + "discountCents": 100 +} +``` + +El backend ignora snapshots antiguos enviados por el cliente y vuelve a cargar nombre, SKU, EAN y precio. También bloquea y actualiza el stock de la tienda. + +### Artículo libre + +```json +{ + "kind": "free", + "name": "Servicio de asesoría", + "unitPriceCents": 2500, + "quantity": 1 +} +``` + +No reserva ni descuenta inventario. La base de datos exige que `product_id` y `variant_id` sean nulos únicamente en estas líneas. + +### Pago + +```json +{ + "methodCode": "cash", + "amountCents": 1000, + "tenderedCents": 2000 +} +``` + +- La suma de `amountCents` debe coincidir exactamente con el total. +- Solo un método de tipo efectivo acepta `tenderedCents`. +- `tenderedCents` debe ser mayor o igual que el importe aplicado. +- El cambio es `tenderedCents - amountCents`. +- Un código inactivo o de otra tienda se rechaza. + +La venta, stock, pagos, líneas de reporting, saldo esperado de caja, número y ticket se confirman en una única transacción idempotente. + +## Ticket + +La respuesta de venta y `GET /pos/sales/:id/receipt` contienen: + +- datos de empresa; +- fecha y hora; +- número de ticket; +- terminal, sesión y cajero; +- artículos, cantidad, precio, subtotal, descuento, IVA y total de línea; +- subtotal, descuentos, IVA y total de venta; +- métodos e importes pagados; +- efectivo entregado y cambio; +- política de devolución. + +### Impresión + +`GET /pos/sales/:id/print` devuelve el mismo payload estructurado. El cliente usa impresión estándar del navegador, sin controlador específico de hardware. + +### Email + +`POST /pos/sales/:id/receipt/email` + +```json +{ "email": "cliente@example.es" } +``` + +Usa la configuración SMTP de **Ajustes → SMTP / Email**. El asunto y contenido se generan exclusivamente desde el ticket; el cliente no puede suministrar HTML arbitrario. + +## Reporting y caja + +Por cada asignación se crea: + +- una transacción en `payments_transactions`; +- una línea `payment` en `reporting_payment_lines` con tienda, terminal, sesión y método. + +El efectivo esperado aumenta por el importe aplicado, no por el efectivo entregado; el cambio no cuenta como ingreso ni efectivo retenido. + +## Próximas ampliaciones + +- F-187: eliminar/desactivar cajeros preservando histórico. +- F-188: ventas con saldo pendiente. +- F-189: cantidades negativas, devoluciones parciales/totales y ticket de devolución. +- F-190: auditoría completa de actualización/refresco de reporting. +- F-191: cierre de terminal y cierre diario conciliando efectivo, tarjetas, devoluciones y pendientes. diff --git a/project/apps/admin/src/app/(dashboard)/pos/page.tsx b/project/apps/admin/src/app/(dashboard)/pos/page.tsx index b15621a..e25992d 100644 --- a/project/apps/admin/src/app/(dashboard)/pos/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/pos/page.tsx @@ -1,9 +1,13 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { api } from '@/lib/api-client'; -interface PosStore { id: string; name: string; active: boolean } +interface PosStore { + id: string; + name: string; + active: boolean; +} interface PosTerminal { id: string; storeId: string; @@ -11,29 +15,106 @@ interface PosTerminal { bindingCode: string | null; boundAt: string | null; status: string; - lastSeenAt: string | null; - settings?: { touchNavigationEnabled?: boolean; quickProductVariantIds?: Array }; + settings?: { + touchNavigationEnabled?: boolean; + quickProductVariantIds?: Array; + lineDiscountsEnabled?: boolean; + }; } -interface CatalogOption { variantId: string; productId: string; name: string; sku: string; priceCents: number } +interface CatalogOption { + variantId: string; + name: string; + sku: string; +} +interface PaymentMethod { + id: string; + storeId: string; + code: string; + label: string; + kind: 'cash' | 'card' | 'other'; + active: boolean; + sortOrder: number; +} +interface ReceiptSettings { + storeId: string; + name: string; + address: string; + taxId: string; + contactEmail: string; + contactPhone: string; + receiptHeader: string; + receiptFooter: string; + prefix: string; + nextNumber: number; + padding: number; + returnPolicy: string; +} + +const emptyReceipt: ReceiptSettings = { + storeId: '', + name: '', + address: '', + taxId: '', + contactEmail: '', + contactPhone: '', + receiptHeader: '', + receiptFooter: '', + prefix: 'TPV', + nextNumber: 1, + padding: 6, + returnPolicy: 'Se admiten devoluciones durante 15 días con el ticket de compra.', +}; export default function PosAdminPage() { const [stores, setStores] = useState([]); const [terminals, setTerminals] = useState([]); + const [catalogOptions, setCatalogOptions] = useState([]); const [storeId, setStoreId] = useState(''); - const [name, setName] = useState(''); + const [terminalName, setTerminalName] = useState(''); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [error, setError] = useState(''); const [newCode, setNewCode] = useState(''); const [copied, setCopied] = useState(''); - const [catalogOptions, setCatalogOptions] = useState([]); const [configuring, setConfiguring] = useState(null); const [touchEnabled, setTouchEnabled] = useState(true); - const [quickSlots, setQuickSlots] = useState>([null, null, null, null]); + const [discountsEnabled, setDiscountsEnabled] = useState(true); + const [quickSlots, setQuickSlots] = useState>( + Array.from({ length: 8 }, () => null), + ); const [savingTouch, setSavingTouch] = useState(false); const [touchMessage, setTouchMessage] = useState(''); + const [paymentMethods, setPaymentMethods] = useState([]); + const [newMethod, setNewMethod] = useState({ + code: '', + label: '', + kind: 'other' as PaymentMethod['kind'], + }); + const [savingMethod, setSavingMethod] = useState(false); + const [paymentMessage, setPaymentMessage] = useState(''); + const [receipt, setReceipt] = useState(emptyReceipt); + const [savingReceipt, setSavingReceipt] = useState(false); + const [receiptMessage, setReceiptMessage] = useState(''); - const load = async () => { + const loadStoreConfiguration = useCallback(async (selectedStoreId: string) => { + if (!selectedStoreId) return; + try { + const [methodData, receiptData] = await Promise.all([ + api.get<{ items: PaymentMethod[] }>( + `/api/pos/admin/payment-methods?storeId=${encodeURIComponent(selectedStoreId)}`, + ), + api.get>( + `/api/pos/admin/receipt-settings?storeId=${encodeURIComponent(selectedStoreId)}`, + ), + ]); + setPaymentMethods(methodData.items); + setReceipt({ ...emptyReceipt, ...receiptData, storeId: selectedStoreId }); + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cargar la configuración de cobro'); + } + }, []); + + const load = useCallback(async () => { setLoading(true); setError(''); try { @@ -45,15 +126,26 @@ export default function PosAdminPage() { setStores(storeData.stores); setTerminals(terminalData.terminals); setCatalogOptions(catalogData.items); - setStoreId((current) => current || storeData.stores.find((store) => store.active)?.id || ''); + const selected = storeId || storeData.stores.find((store) => store.active)?.id || ''; + setStoreId(selected); + await loadStoreConfiguration(selected); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo cargar la configuración TPV'); } finally { setLoading(false); } - }; + }, [loadStoreConfiguration, storeId]); - useEffect(() => { void load(); }, []); + useEffect(() => { + void load(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const changeStore = (nextStoreId: string) => { + setStoreId(nextStoreId); + setReceiptMessage(''); + setPaymentMessage(''); + void loadStoreConfiguration(nextStoreId); + }; const createTerminal = async (event: React.FormEvent) => { event.preventDefault(); @@ -61,8 +153,11 @@ export default function PosAdminPage() { setError(''); setNewCode(''); try { - const terminal = await api.post('/api/pos/admin/terminals', { storeId, name: name.trim() }); - setName(''); + const terminal = await api.post('/api/pos/admin/terminals', { + storeId, + name: terminalName.trim(), + }); + setTerminalName(''); setNewCode(terminal.bindingCode ?? ''); await load(); } catch (err) { @@ -75,8 +170,9 @@ export default function PosAdminPage() { const openTouchConfig = (terminal: PosTerminal) => { setConfiguring(terminal); setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false); + setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false); const configured = terminal.settings?.quickProductVariantIds ?? []; - setQuickSlots([0, 1, 2, 3].map((slot) => configured[slot] ?? null)); + setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null)); setTouchMessage(''); }; @@ -87,9 +183,10 @@ export default function PosAdminPage() { try { await api.patch(`/api/pos/admin/terminals/${configuring.id}/touch-config`, { touchNavigationEnabled: touchEnabled, + lineDiscountsEnabled: discountsEnabled, quickProductVariantIds: quickSlots, }); - setTouchMessage('Configuración táctil guardada'); + setTouchMessage('Configuración del terminal guardada'); await load(); } catch (err) { setTouchMessage(err instanceof Error ? err.message : 'No se pudo guardar'); @@ -98,98 +195,458 @@ export default function PosAdminPage() { } }; + const createPaymentMethod = async (event: React.FormEvent) => { + event.preventDefault(); + setSavingMethod(true); + setPaymentMessage(''); + try { + await api.post('/api/pos/admin/payment-methods', { + storeId, + code: newMethod.code.trim().toLowerCase(), + label: newMethod.label.trim(), + kind: newMethod.kind, + active: true, + sortOrder: paymentMethods.length, + }); + setNewMethod({ code: '', label: '', kind: 'other' }); + setPaymentMessage('Forma de pago creada'); + await loadStoreConfiguration(storeId); + } catch (err) { + setPaymentMessage(err instanceof Error ? err.message : 'No se pudo crear la forma de pago'); + } finally { + setSavingMethod(false); + } + }; + + const togglePaymentMethod = async (method: PaymentMethod) => { + setPaymentMessage(''); + try { + await api.patch(`/api/pos/admin/payment-methods/${method.id}`, { active: !method.active }); + setPaymentMessage(method.active ? 'Forma de pago desactivada' : 'Forma de pago activada'); + await loadStoreConfiguration(storeId); + } catch (err) { + setPaymentMessage(err instanceof Error ? err.message : 'No se pudo actualizar'); + } + }; + + const saveReceipt = async (event: React.FormEvent) => { + event.preventDefault(); + setSavingReceipt(true); + setReceiptMessage(''); + try { + await api.patch('/api/pos/admin/receipt-settings', receipt); + setReceiptMessage('Datos y numeración del ticket guardados'); + await loadStoreConfiguration(storeId); + } catch (err) { + setReceiptMessage(err instanceof Error ? err.message : 'No se pudo guardar el ticket'); + } finally { + setSavingReceipt(false); + } + }; + const copyCode = async (code: string) => { await navigator.clipboard.writeText(code); setCopied(code); - setTimeout(() => setCopied(''), 2000); + window.setTimeout(() => setCopied(''), 2000); }; return (
-
+

TPV

-

Configura dispositivos y obtén el código para vincular cada caja.

-
+

+ Configura terminales táctiles, formas de pago y contenido fiscal del ticket. +

+ -
-
-

1. Vincular terminal

-

Crea un terminal aquí y escribe su código de 8 caracteres en el navegador del TPV. Esto identifica qué caja física está vendiendo.

-
-
-

2. Abrir sesión de caja

-

Después de vincular, el cajero indica el efectivo inicial en el propio TPV. La sesión representa el turno diario de caja.

-
-
+
-

Crear terminal

-
- -