feat(F-186): completed feature

This commit is contained in:
chattie
2026-08-22 22:08:09 +02:00
parent 63a305bdd4
commit a3f6edd325
30 changed files with 3603 additions and 624 deletions

View File

@@ -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
}
}
]
}

View File

@@ -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.

144
docs/pos/POS_CHECKOUT.md Normal file
View File

@@ -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.

View File

@@ -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<string | null> };
settings?: {
touchNavigationEnabled?: boolean;
quickProductVariantIds?: Array<string | null>;
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<PosStore[]>([]);
const [terminals, setTerminals] = useState<PosTerminal[]>([]);
const [catalogOptions, setCatalogOptions] = useState<CatalogOption[]>([]);
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<CatalogOption[]>([]);
const [configuring, setConfiguring] = useState<PosTerminal | null>(null);
const [touchEnabled, setTouchEnabled] = useState(true);
const [quickSlots, setQuickSlots] = useState<Array<string | null>>([null, null, null, null]);
const [discountsEnabled, setDiscountsEnabled] = useState(true);
const [quickSlots, setQuickSlots] = useState<Array<string | null>>(
Array.from({ length: 8 }, () => null),
);
const [savingTouch, setSavingTouch] = useState(false);
const [touchMessage, setTouchMessage] = useState('');
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [newMethod, setNewMethod] = useState({
code: '',
label: '',
kind: 'other' as PaymentMethod['kind'],
});
const [savingMethod, setSavingMethod] = useState(false);
const [paymentMessage, setPaymentMessage] = useState('');
const [receipt, setReceipt] = useState<ReceiptSettings>(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<Partial<ReceiptSettings>>(
`/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<PosTerminal>('/api/pos/admin/terminals', { storeId, name: name.trim() });
setName('');
const terminal = await api.post<PosTerminal>('/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 (
<div className="space-y-6">
<div>
<header>
<h1 className="text-2xl font-bold text-gray-900">TPV</h1>
<p className="mt-0.5 text-sm text-gray-500">Configura dispositivos y obtén el código para vincular cada caja.</p>
</div>
<p className="mt-0.5 text-sm text-gray-500">
Configura terminales táctiles, formas de pago y contenido fiscal del ticket.
</p>
</header>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-blue-200 bg-blue-50 p-5 text-sm text-blue-900">
<h2 className="font-bold">1. Vincular terminal</h2>
<p className="mt-1">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.</p>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900">
<h2 className="font-bold">2. Abrir sesión de caja</h2>
<p className="mt-1">Después de vincular, el cajero indica el efectivo inicial en el propio TPV. La sesión representa el turno diario de caja.</p>
</div>
</div>
<label className="block max-w-md text-sm font-medium text-gray-700">
Tienda que estás configurando
<select
value={storeId}
onChange={(event) => changeStore(event.target.value)}
className="mt-1 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5"
>
{stores
.filter((store) => store.active)
.map((store) => (
<option key={store.id} value={store.id}>
{store.name}
</option>
))}
</select>
</label>
<form onSubmit={createTerminal} className="rounded-xl border border-gray-200 bg-white p-6">
<h2 className="mb-4 font-bold text-gray-900">Crear terminal</h2>
<div className="grid gap-4 md:grid-cols-[1fr_1fr_auto] md:items-end">
<label className="text-sm font-medium text-gray-700">
Tienda
<select value={storeId} onChange={(event) => setStoreId(event.target.value)} required className="mt-1 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5">
{stores.filter((store) => store.active).map((store) => <option key={store.id} value={store.id}>{store.name}</option>)}
</select>
</label>
<label className="text-sm font-medium text-gray-700">
<h2 className="mb-4 font-bold text-gray-900">Crear y vincular terminal</h2>
<div className="flex flex-wrap items-end gap-4">
<label className="min-w-64 flex-1 text-sm font-medium text-gray-700">
Nombre del terminal
<input value={name} onChange={(event) => setName(event.target.value)} required maxLength={100} placeholder="Caja principal" className="mt-1 w-full rounded-xl border border-gray-300 px-3 py-2.5" />
<input
value={terminalName}
onChange={(event) => setTerminalName(event.target.value)}
required
maxLength={100}
placeholder="Caja principal"
className="mt-1 w-full rounded-xl border border-gray-300 px-3 py-2.5"
/>
</label>
<button type="submit" disabled={creating || !storeId || !name.trim()} className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50">
<button
disabled={creating || !storeId || !terminalName.trim()}
className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50"
>
{creating ? 'Creando…' : 'Crear terminal'}
</button>
</div>
{newCode && <p className="mt-4 rounded-lg bg-green-50 p-3 text-sm text-green-800">Código creado: <strong className="font-mono text-lg tracking-widest">{newCode}</strong></p>}
{newCode && (
<p className="mt-4 rounded-lg bg-green-50 p-3 text-sm text-green-800">
Código: <strong className="font-mono text-lg tracking-widest">{newCode}</strong>
</p>
)}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
</form>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<div className="border-b border-gray-200 px-6 py-4"><h2 className="font-bold text-gray-900">Terminales</h2></div>
{loading ? <p className="p-6 text-sm text-gray-500">Cargando</p> : terminals.length === 0 ? <p className="p-6 text-sm text-gray-500">No hay terminales.</p> : (
<section className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<div className="border-b px-6 py-4">
<h2 className="font-bold">Terminales</h2>
</div>
{loading ? (
<p className="p-6 text-sm text-gray-500">Cargando</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500"><tr><th className="px-4 py-3">Nombre</th><th className="px-4 py-3">Tienda</th><th className="px-4 py-3">Código</th><th className="px-4 py-3">Estado</th><th className="px-4 py-3">Vinculado</th><th className="px-4 py-3">Interfaz</th></tr></thead>
<tbody className="divide-y divide-gray-100">
{terminals.map((terminal) => {
const store = stores.find((item) => item.id === terminal.storeId);
return <tr key={terminal.id}><td className="px-4 py-3 font-medium">{terminal.name}</td><td className="px-4 py-3">{store?.name ?? terminal.storeId}</td><td className="px-4 py-3"><button type="button" onClick={() => terminal.bindingCode && void copyCode(terminal.bindingCode)} disabled={!terminal.bindingCode} className="rounded-lg bg-gray-100 px-3 py-1 font-mono tracking-wider hover:bg-gray-200 disabled:opacity-40">{copied === terminal.bindingCode ? 'Copiado' : terminal.bindingCode ?? '—'}</button></td><td className="px-4 py-3 capitalize">{terminal.status}</td><td className="px-4 py-3">{terminal.boundAt ? 'Sí' : 'No'}</td><td className="px-4 py-3"><button type="button" onClick={() => openTouchConfig(terminal)} className="rounded-lg border border-[#2D6A4F] px-3 py-1.5 text-xs font-semibold text-[#2D6A4F] hover:bg-green-50">Configurar táctil</button></td></tr>;
})}
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
<tr>
<th className="px-4 py-3">Nombre</th>
<th className="px-4 py-3">Código</th>
<th className="px-4 py-3">Estado</th>
<th className="px-4 py-3">Vinculado</th>
<th className="px-4 py-3">Configuración</th>
</tr>
</thead>
<tbody className="divide-y">
{terminals
.filter((terminal) => terminal.storeId === storeId)
.map((terminal) => (
<tr key={terminal.id}>
<td className="px-4 py-3 font-medium">{terminal.name}</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() =>
terminal.bindingCode && void copyCode(terminal.bindingCode)
}
disabled={!terminal.bindingCode}
className="rounded-lg bg-gray-100 px-3 py-1 font-mono"
>
{copied === terminal.bindingCode
? 'Copiado'
: (terminal.bindingCode ?? '—')}
</button>
</td>
<td className="px-4 py-3">{terminal.status}</td>
<td className="px-4 py-3">{terminal.boundAt ? 'Sí' : 'No'}</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => openTouchConfig(terminal)}
className="rounded-lg border border-[#2D6A4F] px-3 py-1.5 text-xs font-semibold text-[#2D6A4F]"
>
Configurar terminal
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
{configuring && (
<section className="rounded-xl border border-gray-200 bg-white p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex justify-between">
<div>
<h2 className="font-bold text-gray-900">Interfaz táctil · {configuring.name}</h2>
<p className="mt-1 text-sm text-gray-500">Las categorías y subcategorías se administran en Categorías. Aquí activas su navegación en este terminal y eliges cuatro productos rápidos.</p>
<h2 className="font-bold">Interfaz táctil · {configuring.name}</h2>
<p className="mt-1 text-sm text-gray-500">
Elige hasta ocho productos rápidos y las funciones disponibles en esta caja.
</p>
</div>
<button type="button" onClick={() => setConfiguring(null)} className="text-gray-400 hover:text-gray-700" aria-label="Cerrar"></button>
<button type="button" onClick={() => setConfiguring(null)} aria-label="Cerrar">
</button>
</div>
<label className="mt-5 flex items-center gap-3 rounded-xl bg-gray-50 p-4 text-sm font-medium text-gray-800">
<input type="checkbox" checked={touchEnabled} onChange={(event) => setTouchEnabled(event.target.checked)} className="h-5 w-5 accent-[#2D6A4F]" />
Activar botones grandes categoría subcategoría producto
</label>
<div className="mt-5 grid gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-xl bg-gray-50 p-4 text-sm font-medium">
<input
type="checkbox"
checked={touchEnabled}
onChange={(event) => setTouchEnabled(event.target.checked)}
className="h-5 w-5 accent-[#2D6A4F]"
/>{' '}
Navegación táctil por categorías
</label>
<label className="flex items-center gap-3 rounded-xl bg-gray-50 p-4 text-sm font-medium">
<input
type="checkbox"
checked={discountsEnabled}
onChange={(event) => setDiscountsEnabled(event.target.checked)}
className="h-5 w-5 accent-[#2D6A4F]"
/>{' '}
Permitir descuentos por línea
</label>
</div>
<p className="mt-2 text-xs text-gray-500">
Desactiva descuentos para terminales de autopago.
</p>
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{quickSlots.map((variantId, slot) => (
<label key={slot} className="text-sm font-medium text-gray-700">
Producto rápido {slot + 1}
<select value={variantId ?? ''} onChange={(event) => setQuickSlots((current) => current.map((value, index) => index === slot ? event.target.value || null : value))} className="mt-1 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5">
<select
value={variantId ?? ''}
onChange={(event) =>
setQuickSlots((current) =>
current.map((value, index) =>
index === slot ? event.target.value || null : value,
),
)
}
className="mt-1 w-full rounded-xl border bg-white px-3 py-2.5"
>
<option value="">Sin asignar</option>
{catalogOptions.map((product) => <option key={product.variantId} value={product.variantId}>{product.name} · {product.sku}</option>)}
{catalogOptions.map((product) => (
<option key={product.variantId} value={product.variantId}>
{product.name} · {product.sku}
</option>
))}
</select>
</label>
))}
</div>
<div className="mt-5 flex items-center gap-4">
<button type="button" onClick={() => void saveTouchConfig()} disabled={savingTouch} className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50">{savingTouch ? 'Guardando…' : 'Guardar interfaz táctil'}</button>
{touchMessage && <p className={`text-sm ${touchMessage.includes('guardada') ? 'text-green-700' : 'text-red-600'}`}>{touchMessage}</p>}
<button
type="button"
onClick={() => void saveTouchConfig()}
disabled={savingTouch}
className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50"
>
{savingTouch ? 'Guardando…' : 'Guardar terminal'}
</button>
{touchMessage && <p className="text-sm">{touchMessage}</p>}
</div>
</section>
)}
<section className="rounded-xl border border-gray-200 bg-white p-6">
<div>
<h2 className="font-bold">Formas de pago</h2>
<p className="mt-1 text-sm text-gray-500">
Crea Bizum, Stripe, Apple Pay u otro método. Tarjeta y otro se registran
manualmente; no activan una pasarela externa.
</p>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{paymentMethods.map((method) => (
<div
key={method.id}
className={`flex items-center justify-between rounded-xl border p-4 ${method.active ? 'border-green-200 bg-green-50' : 'border-gray-200 bg-gray-50 opacity-70'}`}
>
<div>
<strong className="block">{method.label}</strong>
<span className="text-xs text-gray-500">
{method.code} · {method.kind}
</span>
</div>
<button
type="button"
onClick={() => void togglePaymentMethod(method)}
className="rounded-lg border bg-white px-3 py-2 text-xs font-semibold"
>
{method.active ? 'Desactivar' : 'Activar'}
</button>
</div>
))}
</div>
<form
onSubmit={createPaymentMethod}
className="mt-5 grid gap-3 rounded-xl bg-gray-50 p-4 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end"
>
<label className="text-sm font-medium">
Código
<input
value={newMethod.code}
onChange={(event) =>
setNewMethod({
...newMethod,
code: event.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''),
})
}
required
maxLength={32}
placeholder="bizum"
className="mt-1 w-full rounded-xl border px-3 py-2.5"
/>
</label>
<label className="text-sm font-medium">
Nombre visible
<input
value={newMethod.label}
onChange={(event) => setNewMethod({ ...newMethod, label: event.target.value })}
required
maxLength={64}
placeholder="Bizum"
className="mt-1 w-full rounded-xl border px-3 py-2.5"
/>
</label>
<label className="text-sm font-medium">
Tipo
<select
value={newMethod.kind}
onChange={(event) =>
setNewMethod({ ...newMethod, kind: event.target.value as PaymentMethod['kind'] })
}
className="mt-1 w-full rounded-xl border bg-white px-3 py-2.5"
>
<option value="cash">Efectivo</option>
<option value="card">Tarjeta</option>
<option value="other">Otro</option>
</select>
</label>
<button
disabled={savingMethod}
className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50"
>
Añadir
</button>
</form>
{paymentMessage && <p className="mt-3 text-sm">{paymentMessage}</p>}
</section>
<form onSubmit={saveReceipt} className="rounded-xl border border-gray-200 bg-white p-6">
<div>
<h2 className="font-bold">Empresa, numeración y política del ticket</h2>
<p className="mt-1 text-sm text-gray-500">
Estos datos se congelan en cada ticket emitido.
</p>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Field
label="Razón social / nombre"
value={receipt.name}
onChange={(value) => setReceipt({ ...receipt, name: value })}
required
/>
<Field
label="NIF / CIF"
value={receipt.taxId}
onChange={(value) => setReceipt({ ...receipt, taxId: value })}
/>
<Field
label="Dirección"
value={receipt.address}
onChange={(value) => setReceipt({ ...receipt, address: value })}
/>
<Field
label="Email"
type="email"
value={receipt.contactEmail}
onChange={(value) => setReceipt({ ...receipt, contactEmail: value })}
/>
<Field
label="Teléfono"
value={receipt.contactPhone}
onChange={(value) => setReceipt({ ...receipt, contactPhone: value })}
/>
<Field
label="Cabecera"
value={receipt.receiptHeader}
onChange={(value) => setReceipt({ ...receipt, receiptHeader: value })}
/>
<Field
label="Pie"
value={receipt.receiptFooter}
onChange={(value) => setReceipt({ ...receipt, receiptFooter: value })}
/>
<Field
label="Prefijo de ticket"
value={receipt.prefix}
onChange={(value) =>
setReceipt({ ...receipt, prefix: value.toUpperCase().replace(/[^A-Z0-9_-]/g, '') })
}
required
/>
<Field
label="Próximo número"
type="number"
value={String(receipt.nextNumber)}
onChange={(value) => setReceipt({ ...receipt, nextNumber: Number(value) })}
required
min={1}
/>
<Field
label="Dígitos (relleno)"
type="number"
value={String(receipt.padding)}
onChange={(value) => setReceipt({ ...receipt, padding: Number(value) })}
required
min={1}
max={12}
/>
</div>
<label className="mt-4 block text-sm font-medium">
Política de devolución
<textarea
value={receipt.returnPolicy}
onChange={(event) => setReceipt({ ...receipt, returnPolicy: event.target.value })}
required
maxLength={2000}
rows={3}
className="mt-1 w-full rounded-xl border px-3 py-2.5"
/>
</label>
<div className="mt-5 flex items-center gap-4">
<button
disabled={savingReceipt}
className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50"
>
{savingReceipt ? 'Guardando…' : 'Guardar ticket'}
</button>
{receiptMessage && <p className="text-sm">{receiptMessage}</p>}
</div>
</form>
</div>
);
}
function Field({
label,
value,
onChange,
type = 'text',
required = false,
min,
max,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
required?: boolean;
min?: number;
max?: number;
}) {
return (
<label className="text-sm font-medium text-gray-700">
{label}
<input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
required={required}
min={min}
max={max}
className="mt-1 w-full rounded-xl border border-gray-300 px-3 py-2.5"
/>
</label>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
interface FreeItemModalProps {
onAdd: (item: { name: string; unitPriceCents: number }) => void;
onClose: () => void;
}
export default function FreeItemModal({ onAdd, onClose }: FreeItemModalProps) {
const [name, setName] = useState('');
const [price, setPrice] = useState('');
const [error, setError] = useState('');
const submit = (event: React.FormEvent) => {
event.preventDefault();
const normalized = price.trim().replace(',', '.');
const unitPriceCents = /^\d+(?:\.\d{1,2})?$/.test(normalized)
? Math.round(Number(normalized) * 100)
: 0;
if (!name.trim() || unitPriceCents <= 0) {
setError('Indica un nombre y un precio positivo');
return;
}
onAdd({ name: name.trim(), unitPriceCents });
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="free-item-title"
>
<form
onSubmit={submit}
className="w-full max-w-md space-y-5 rounded-2xl bg-white p-6 shadow-2xl"
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-[#2D6A4F]">Sin producto de almacén</p>
<h2 id="free-item-title" className="text-2xl font-bold text-gray-900">
Artículo libre
</h2>
</div>
<button
type="button"
onClick={onClose}
className="min-h-12 min-w-12 rounded-xl bg-gray-100 text-xl"
aria-label="Cerrar"
>
</button>
</div>
<label className="block text-sm font-semibold text-gray-700">
Nombre o servicio
<input
value={name}
onChange={(event) => {
setName(event.target.value);
setError('');
}}
required
maxLength={200}
autoFocus
className="mt-1 min-h-12 w-full rounded-xl border-2 border-gray-200 px-4 outline-none focus:border-[#2D6A4F]"
placeholder="Ej. Asesoría nutricional"
/>
</label>
<label className="block text-sm font-semibold text-gray-700">
Precio ()
<input
value={price}
onChange={(event) => {
setPrice(event.target.value);
setError('');
}}
required
inputMode="decimal"
className="mt-1 min-h-12 w-full rounded-xl border-2 border-gray-200 px-4 text-xl font-bold outline-none focus:border-[#2D6A4F]"
placeholder="0,00"
/>
</label>
{error && (
<p className="text-sm font-medium text-red-600" aria-live="polite">
{error}
</p>
)}
<button className="min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white">
Añadir al ticket
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,168 @@
'use client';
import { useMemo, useState } from 'react';
import { formatPrice } from '@/lib/money';
import type { PaymentAllocation, PaymentMethod } from '@/types/checkout';
interface PaymentModalProps {
method: PaymentMethod;
remainingCents: number;
onAdd: (allocation: PaymentAllocation) => void;
onClose: () => void;
}
export default function PaymentModal({
method,
remainingCents,
onAdd,
onClose,
}: PaymentModalProps) {
const [mode, setMode] = useState<'total' | 'partial'>('total');
const [amount, setAmount] = useState(centsToInput(remainingCents));
const [tendered, setTendered] = useState(centsToInput(remainingCents));
const [error, setError] = useState('');
const amountCents = useMemo(() => parseEuros(amount), [amount]);
const tenderedCents = useMemo(() => parseEuros(tendered), [tendered]);
const changeCents =
method.kind === 'cash' && tenderedCents >= amountCents ? tenderedCents - amountCents : 0;
const chooseMode = (next: 'total' | 'partial') => {
setMode(next);
setError('');
if (next === 'total') {
setAmount(centsToInput(remainingCents));
setTendered(centsToInput(remainingCents));
} else {
setAmount('');
setTendered('');
}
};
const add = () => {
if (amountCents <= 0 || amountCents > remainingCents) {
setError('El importe debe ser positivo y no superar lo pendiente');
return;
}
if (method.kind === 'cash' && tenderedCents < amountCents) {
setError('El efectivo entregado debe cubrir el importe aplicado');
return;
}
onAdd({
id: crypto.randomUUID(),
methodCode: method.code,
methodLabel: method.label,
kind: method.kind,
amountCents,
...(method.kind === 'cash' ? { tenderedCents } : {}),
changeCents,
});
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="payment-title"
>
<div className="w-full max-w-md space-y-5 rounded-2xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-[#2D6A4F]">
Pendiente {formatPrice(remainingCents)}
</p>
<h2 id="payment-title" className="text-2xl font-bold text-gray-900">
{method.label}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="min-h-12 min-w-12 rounded-xl bg-gray-100 text-xl"
aria-label="Cerrar pago"
>
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => chooseMode('total')}
className={`min-h-14 rounded-xl border-2 font-bold ${mode === 'total' ? 'border-[#2D6A4F] bg-green-50 text-[#2D6A4F]' : 'border-gray-200'}`}
>
Paga el total
</button>
<button
type="button"
onClick={() => chooseMode('partial')}
className={`min-h-14 rounded-xl border-2 font-bold ${mode === 'partial' ? 'border-[#2D6A4F] bg-green-50 text-[#2D6A4F]' : 'border-gray-200'}`}
>
Paga una parte
</button>
</div>
<label className="block text-sm font-semibold text-gray-700">
Importe que se aplica ()
<input
value={amount}
onChange={(event) => {
setAmount(event.target.value);
setError('');
}}
inputMode="decimal"
readOnly={mode === 'total'}
autoFocus={mode === 'partial'}
className="mt-1 w-full rounded-xl border-2 border-gray-200 px-4 py-3 text-2xl font-bold outline-none focus:border-[#2D6A4F] read-only:bg-gray-50"
aria-describedby="payment-error"
/>
</label>
{method.kind === 'cash' && (
<label className="block text-sm font-semibold text-gray-700">
Efectivo entregado ()
<input
value={tendered}
onChange={(event) => {
setTendered(event.target.value);
setError('');
}}
inputMode="decimal"
className="mt-1 w-full rounded-xl border-2 border-gray-200 px-4 py-3 text-2xl font-bold outline-none focus:border-[#2D6A4F]"
/>
</label>
)}
{method.kind === 'cash' && tenderedCents >= amountCents && amountCents > 0 && (
<div className="flex items-center justify-between rounded-xl bg-amber-50 p-4 text-amber-900">
<span className="font-semibold">A devolver</span>
<strong className="text-2xl">{formatPrice(changeCents)}</strong>
</div>
)}
{error && (
<p id="payment-error" className="text-sm font-medium text-red-600" aria-live="polite">
{error}
</p>
)}
<button
type="button"
onClick={add}
className="min-h-14 w-full rounded-xl bg-[#2D6A4F] px-5 font-bold text-white hover:bg-[#1B4332]"
>
Añadir pago
</button>
</div>
</div>
);
}
function parseEuros(value: string): number {
const normalized = value.trim().replace(',', '.');
if (!/^\d+(?:\.\d{0,2})?$/.test(normalized)) return 0;
return Math.round(Number(normalized) * 100);
}
function centsToInput(cents: number): string {
return (cents / 100).toFixed(2);
}

View File

@@ -0,0 +1,198 @@
'use client';
import { useState } from 'react';
import { posApi } from '@/lib/api-client';
import { formatPrice } from '@/lib/money';
import type { PosReceipt } from '@/types/checkout';
interface ReceiptModalProps {
receipt: PosReceipt;
initialEmail?: string;
onDelivered: () => void;
}
export default function ReceiptModal({
receipt,
initialEmail = '',
onDelivered,
}: ReceiptModalProps) {
const [email, setEmail] = useState(initialEmail);
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const printReceipt = () => {
window.print();
onDelivered();
};
const emailReceipt = async (event: React.FormEvent) => {
event.preventDefault();
setSending(true);
setError('');
try {
await posApi.emailReceipt(receipt.orderId, email.trim());
onDelivered();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo enviar el ticket');
} finally {
setSending(false);
}
};
return (
<div
className="fixed inset-0 z-[60] overflow-y-auto bg-black/50 p-4 print:static print:bg-white print:p-0"
role="dialog"
aria-modal="true"
aria-labelledby="receipt-title"
>
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white p-6 shadow-2xl print:max-w-none print:rounded-none print:p-0 print:shadow-none">
<article className="space-y-4 text-sm text-gray-900">
<header className="border-b border-dashed border-gray-400 pb-4 text-center">
{receipt.header && <p className="font-semibold">{receipt.header}</p>}
<h2 id="receipt-title" className="text-2xl font-bold">
{receipt.company.name}
</h2>
{receipt.company.address && <p>{receipt.company.address}</p>}
{receipt.company.taxId && <p>NIF/CIF: {receipt.company.taxId}</p>}
{(receipt.company.phone || receipt.company.email) && (
<p>{[receipt.company.phone, receipt.company.email].filter(Boolean).join(' · ')}</p>
)}
</header>
<div className="grid grid-cols-2 gap-2 text-xs">
<p>
<strong>Ticket:</strong> {receipt.receiptNumber}
</p>
<p className="text-right">
<strong>Fecha:</strong> {new Date(receipt.issuedAt).toLocaleString('es-ES')}
</p>
<p>
<strong>Terminal:</strong> {receipt.terminal.name}
</p>
<p className="text-right">
<strong>Cajero:</strong> {receipt.cashier}
</p>
</div>
<table className="w-full border-collapse text-left">
<thead className="border-y border-dashed border-gray-400 text-xs uppercase">
<tr>
<th className="py-2">Artículo</th>
<th className="py-2 text-right">Cant.</th>
<th className="py-2 text-right">Subtotal</th>
</tr>
</thead>
<tbody>
{receipt.items.map((item, index) => (
<tr key={`${item.sku}-${index}`} className="border-b border-gray-100 align-top">
<td className="py-2">
<span className="font-medium">{item.name}</span>
<span className="block text-xs text-gray-500">
{formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''}
</span>
{item.discountCents > 0 && (
<span className="block text-xs text-red-600">
Dto. {formatPrice(item.discountCents)}
</span>
)}
</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-right font-medium">{formatPrice(item.totalCents)}</td>
</tr>
))}
</tbody>
</table>
<div className="ml-auto w-full max-w-xs space-y-1">
<p className="flex justify-between">
<span>Subtotal</span>
<span>{formatPrice(receipt.subtotalCents)}</span>
</p>
{receipt.discountCents > 0 && (
<p className="flex justify-between text-red-600">
<span>Descuentos</span>
<span>{formatPrice(receipt.discountCents)}</span>
</p>
)}
<p className="flex justify-between">
<span>IVA</span>
<span>{formatPrice(receipt.taxCents)}</span>
</p>
<p className="flex justify-between border-t border-gray-900 pt-2 text-xl font-bold">
<span>Total</span>
<span>{formatPrice(receipt.totalCents)}</span>
</p>
</div>
<section className="border-y border-dashed border-gray-400 py-3">
<h3 className="mb-2 font-bold">Formas de pago</h3>
{receipt.payments.map((payment, index) => (
<div key={`${payment.methodCode}-${index}`} className="flex justify-between">
<span>{payment.methodLabel}</span>
<span>{formatPrice(payment.amountCents)}</span>
{payment.tenderedCents !== null && (
<span className="text-xs text-gray-500">
Entregado {formatPrice(payment.tenderedCents)}
</span>
)}
{payment.changeCents > 0 && (
<strong>Cambio {formatPrice(payment.changeCents)}</strong>
)}
</div>
))}
{receipt.changeCents > 0 && (
<p className="mt-2 flex justify-between rounded bg-amber-50 p-2 text-lg font-bold">
<span>Total a devolver</span>
<span>{formatPrice(receipt.changeCents)}</span>
</p>
)}
</section>
<footer className="space-y-2 text-center text-xs">
<p>{receipt.returnPolicy}</p>
{receipt.footer && <p className="font-semibold">{receipt.footer}</p>}
</footer>
</article>
<div className="mt-6 space-y-3 border-t pt-5 print:hidden">
<p className="text-center text-sm text-gray-600">
Imprime o envía el ticket para preparar la siguiente venta.
</p>
<button
type="button"
onClick={printReceipt}
className="min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white"
>
🖨 Imprimir ticket
</button>
<form onSubmit={emailReceipt} className="flex gap-2">
<label className="sr-only" htmlFor="receipt-email">
Email del ticket
</label>
<input
id="receipt-email"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="cliente@email.es"
className="min-h-14 min-w-0 flex-1 rounded-xl border-2 border-gray-200 px-4 outline-none focus:border-[#2D6A4F]"
/>
<button
disabled={sending}
className="min-h-14 rounded-xl bg-blue-600 px-5 font-bold text-white disabled:opacity-50"
>
{sending ? 'Enviando…' : '✉ Enviar'}
</button>
</form>
{error && (
<p className="text-sm font-medium text-red-600" aria-live="polite">
{error}
</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -12,14 +12,14 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText })) as {
const err = (await res.json().catch(() => ({ message: res.statusText }))) as {
message?: string;
error?: { message?: string; code?: string };
};
throw Object.assign(
new Error(err.error?.message ?? err.message ?? 'Request failed'),
{ status: res.status, code: err.error?.code },
);
throw Object.assign(new Error(err.error?.message ?? err.message ?? 'Request failed'), {
status: res.status,
code: err.error?.code,
});
}
return res.json() as Promise<T>;
}
@@ -41,11 +41,13 @@ export const posApi = {
method: 'POST',
body: JSON.stringify({ openingCashCents }),
}),
/** Load touch category navigation and four terminal quick products. */
/** Load touch category navigation and eight terminal quick products. */
touchCatalog: <T>() => apiFetch<T>('/pos/catalog/touch'),
/** List products by query. */
searchProducts: (q: string, storeId?: string, limit = 20) =>
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),
apiFetch(
`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`,
),
/** Get product by EAN. */
productByEan: (ean: string) => apiFetch(`/pos/products/by-ean/${encodeURIComponent(ean)}`),
/** Get product by SKU. */
@@ -55,10 +57,22 @@ export const posApi = {
/** Create an identity customer from the register. */
createCustomer: <T>(data: { email: string; displayName?: string; phone?: string }) =>
apiFetch<T>('/pos/customers', { method: 'POST', body: JSON.stringify(data) }),
/** Atomically confirm a fully allocated sale. */
createSale: <T>(data: unknown) =>
apiFetch<T>('/pos/sales', { method: 'POST', body: JSON.stringify(data) }),
/** Email the immutable generated receipt. */
emailReceipt: <T>(orderId: string, email: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/receipt/email`, {
method: 'POST',
body: JSON.stringify({ email }),
}),
};
export const authApi = {
login: (email: string, password: string) =>
apiFetch('/backoffice/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
apiFetch('/backoffice/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
logout: () => apiFetch('/backoffice/auth/logout', { method: 'POST' }),
};

View File

@@ -0,0 +1,72 @@
export interface PaymentMethod {
id: string;
code: string;
label: string;
kind: 'cash' | 'card' | 'other';
}
export interface PaymentAllocation {
id: string;
methodCode: string;
methodLabel: string;
kind: PaymentMethod['kind'];
amountCents: number;
tenderedCents?: number;
changeCents: number;
}
export interface ReceiptItem {
name: string;
sku: string;
quantity: number;
unitPriceCents: number;
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
freeItem: boolean;
}
export interface ReceiptPayment {
methodCode: string;
methodLabel: string;
kind: PaymentMethod['kind'];
amountCents: number;
tenderedCents: number | null;
changeCents: number;
}
export interface PosReceipt {
receiptNumber: string;
orderId: string;
issuedAt: string;
company: {
name: string;
address: string | null;
taxId: string | null;
email: string | null;
phone: string | null;
};
terminal: { id: string; name: string };
cashier: string;
sessionId: string;
customerEmail: string | null;
items: ReceiptItem[];
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
payments: ReceiptPayment[];
changeCents: number;
header: string | null;
returnPolicy: string;
footer: string | null;
}
export interface PosSaleResponse {
orderId: string;
receiptNumber: string;
totalCents: number;
changeCents: number;
receipt: PosReceipt;
}

View File

@@ -0,0 +1,68 @@
/**
* F-186 — Configurable POS receipts and free sale lines.
*
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
const DEFAULT_STORE_ID = '00000000-0000-0000-0000-000000000001';
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS pos_receipt_settings (
store_id uuid PRIMARY KEY REFERENCES pos_stores(id) ON DELETE CASCADE,
prefix text NOT NULL DEFAULT 'TPV',
next_number bigint NOT NULL DEFAULT 1,
padding integer NOT NULL DEFAULT 6,
return_policy text NOT NULL DEFAULT 'Se admiten devoluciones durante 15 días con el ticket de compra.',
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pos_receipt_settings_next_positive CHECK (next_number > 0),
CONSTRAINT pos_receipt_settings_padding_check CHECK (padding BETWEEN 1 AND 12),
CONSTRAINT pos_receipt_settings_prefix_check CHECK (prefix ~ '^[A-Za-z0-9_-]{1,16}$')
)
`);
pgm.sql(`
INSERT INTO pos_receipt_settings (store_id)
VALUES ('${DEFAULT_STORE_ID}')
ON CONFLICT (store_id) DO NOTHING
`);
pgm.sql('ALTER TABLE orders_orders ADD COLUMN IF NOT EXISTS receipt_number text');
pgm.sql(`
CREATE UNIQUE INDEX IF NOT EXISTS orders_orders_receipt_number_unique_idx
ON orders_orders (store_id, receipt_number)
WHERE receipt_number IS NOT NULL
`);
pgm.sql('ALTER TABLE orders_items ALTER COLUMN product_id DROP NOT NULL');
pgm.sql('ALTER TABLE orders_items ALTER COLUMN variant_id DROP NOT NULL');
pgm.sql(
'ALTER TABLE orders_items ADD COLUMN IF NOT EXISTS is_free_item boolean NOT NULL DEFAULT false',
);
pgm.sql(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'orders_items_stock_or_free_check'
) THEN
ALTER TABLE orders_items
ADD CONSTRAINT orders_items_stock_or_free_check
CHECK (
(is_free_item AND product_id IS NULL AND variant_id IS NULL)
OR
(NOT is_free_item AND product_id IS NOT NULL AND variant_id IS NOT NULL)
);
END IF;
END $$
`);
};
export const down = (pgm) => {
pgm.sql('ALTER TABLE orders_items DROP CONSTRAINT IF EXISTS orders_items_stock_or_free_check');
pgm.sql('ALTER TABLE orders_items DROP COLUMN IF EXISTS is_free_item');
pgm.sql('ALTER TABLE orders_items ALTER COLUMN variant_id SET NOT NULL');
pgm.sql('ALTER TABLE orders_items ALTER COLUMN product_id SET NOT NULL');
pgm.sql('DROP INDEX IF EXISTS orders_orders_receipt_number_unique_idx');
pgm.sql('ALTER TABLE orders_orders DROP COLUMN IF EXISTS receipt_number');
pgm.dropTable('pos_receipt_settings', { ifExists: true });
};

View File

@@ -35,15 +35,14 @@ import { createPromotionService, registerPromotionsRoutes } from '../modules/pro
import { registerCartRoutes } from '../modules/cart/index.js';
import { registerShippingRoutes } from '../modules/shipping/index.js';
import { registerOrdersRoutes } from '../modules/orders/index.js';
import { PgStoreRepository } from '../modules/pos/infrastructure/pg-store-repository.js';
import { PgTerminalRepository } from '../modules/pos/infrastructure/pg-terminal-repository.js';
import { PgPaymentMethodRepository } from '../modules/pos/infrastructure/pg-payment-method-repository.js';
import { PgCashSessionRepository } from '../modules/pos/infrastructure/pg-cash-session-repository.js';
import { registerPosRoutes } from '../modules/pos/index.js';
import { registerCheckoutRoutes } from '../modules/checkout/index.js';
import { registerPaymentsRoutes } from '../modules/payments/index.js';
import { registerNotificationsRoutes, SettingsEmailProvider } from '../modules/notifications/index.js';
import {
registerNotificationsRoutes,
SettingsEmailProvider,
} from '../modules/notifications/index.js';
import { registerReportingRoutes } from '../modules/reporting/index.js';
import { registerPosRoutes } from '../modules/pos/api/pos.routes.js';
import { registerReviewsRoutes } from '../modules/reviews/index.js';
import { registerCmsRoutes } from '../modules/cms/index.js';
import { registerStoreSettingsRoutes } from '../modules/store-settings/index.js';
@@ -309,12 +308,6 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
});
});
// POS module repositories (wired here so POS-004+ can use them)
const posStoreRepo = deps.pool ? new PgStoreRepository(deps.pool) : null;
const posTerminalRepo = deps.pool ? new PgTerminalRepository(deps.pool) : null;
const posPaymentMethodRepo = deps.pool ? new PgPaymentMethodRepository(deps.pool) : null;
const posSessionRepo = deps.pool ? new PgCashSessionRepository(deps.pool) : null;
await app.register(async (instance) => {
await registerOrdersRoutes(instance, {
pool: deps.pool as pg.Pool,

View File

@@ -0,0 +1,148 @@
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { CreatePosSaleUseCase } from '../../modules/pos/index.js';
import { createPool } from '../../infrastructure/db/pool.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
const STORE_ID = '00000000-0000-0000-0000-000000000001';
const USER_ID = '10000000-0000-4000-8000-000000000001';
const TERMINAL_ID = '20000000-0000-4000-8000-000000000001';
const SESSION_ID = '30000000-0000-4000-8000-000000000001';
const PRODUCT_ID = '40000000-0000-4000-8000-000000000001';
const VARIANT_ID = '50000000-0000-4000-8000-000000000001';
describe.skipIf(!hasDb)('F-186 POS checkout and receipts (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
await pool.query(
`INSERT INTO backoffice_users (id, email, password_hash, role)
VALUES ($1, 'cashier@example.test', 'test-hash', 'pos_cashier')`,
[USER_ID],
);
await pool.query(
`INSERT INTO pos_terminals (id, store_id, name, settings)
VALUES ($1, $2, 'Caja test', '{"lineDiscountsEnabled":true}'::jsonb)`,
[TERMINAL_ID, STORE_ID],
);
await pool.query(
`INSERT INTO pos_cash_sessions (
id, terminal_id, store_id, user_id, opening_cash_cents, expected_cash_cents
) VALUES ($1, $2, $3, $4, 2000, NULL)`,
[SESSION_ID, TERMINAL_ID, STORE_ID, USER_ID],
);
await pool.query(
`INSERT INTO catalog_products (id, name, slug, state)
VALUES ($1, 'Producto test', 'producto-test', 'active')`,
[PRODUCT_ID],
);
await pool.query(
`INSERT INTO catalog_product_variants (id, product_id, sku, ean)
VALUES ($1, $2, 'SKU-TEST', '8412345678901')`,
[VARIANT_ID, PRODUCT_ID],
);
await pool.query(
`INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, vat_rate)
VALUES ($1, 700, 'reduced')`,
[VARIANT_ID],
);
await pool.query(
`INSERT INTO inventory_stock (variant_id, store_id, available)
VALUES ($1, $2, 3)`,
[VARIANT_ID, STORE_ID],
);
});
afterAll(async () => {
await pool.end();
});
it('atomically records mixed payment, change, free item, reporting and receipt', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const input = {
idempotencyKey: 'f186-sale-1',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [
{ kind: 'stock' as const, variantId: VARIANT_ID, quantity: 1, discountCents: 0 },
{ kind: 'free' as const, name: 'Servicio libre', unitPriceCents: 500, quantity: 1 },
],
payments: [
{ methodCode: 'cash', amountCents: 500, tenderedCents: 1000 },
{ methodCode: 'card', amountCents: 700 },
],
};
const result = await useCase.execute(input);
expect(result.totalCents).toBe(1200);
expect(result.changeCents).toBe(500);
expect(result.receiptNumber).toBe('TPV-000001');
expect(result.receipt.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'Producto test', freeItem: false, totalCents: 700 }),
expect.objectContaining({ name: 'Servicio libre', freeItem: true, totalCents: 500 }),
]),
);
expect(result.receipt.payments).toEqual(
expect.arrayContaining([
expect.objectContaining({ methodCode: 'cash', amountCents: 500, changeCents: 500 }),
expect.objectContaining({ methodCode: 'card', amountCents: 700 }),
]),
);
const order = await pool.query<{ state: string; receipt_number: string }>(
'SELECT state, receipt_number FROM orders_orders WHERE id = $1',
[result.orderId],
);
expect(order.rows[0]).toEqual({ state: 'COMPLETED', receipt_number: 'TPV-000001' });
const freeLine = await pool.query<{
product_id: string | null;
variant_id: string | null;
is_free_item: boolean;
}>(
`SELECT product_id, variant_id, is_free_item FROM orders_items
WHERE order_id = $1 AND is_free_item = true`,
[result.orderId],
);
expect(freeLine.rows[0]).toEqual({ product_id: null, variant_id: null, is_free_item: true });
const stock = await pool.query<{ available: number; sold: number }>(
'SELECT available, sold FROM inventory_stock WHERE variant_id = $1 AND store_id = $2',
[VARIANT_ID, STORE_ID],
);
expect(stock.rows[0]).toEqual({ available: 2, sold: 1 });
const reporting = await pool.query<{ count: number }>(
'SELECT COUNT(*)::int AS count FROM reporting_payment_lines WHERE order_id = $1',
[result.orderId],
);
expect(reporting.rows[0]?.count).toBe(2);
const session = await pool.query<{ expected_cash_cents: number }>(
'SELECT expected_cash_cents FROM pos_cash_sessions WHERE id = $1',
[SESSION_ID],
);
expect(session.rows[0]?.expected_cash_cents).toBe(2500);
const replay = await useCase.execute(input);
expect(replay.orderId).toBe(result.orderId);
expect(replay.receiptNumber).toBe(result.receiptNumber);
const afterReplay = await pool.query<{ available: number }>(
'SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2',
[VARIANT_ID, STORE_ID],
);
expect(afterReplay.rows[0]?.available).toBe(2);
});
});

View File

@@ -8,5 +8,6 @@ export {
type IdentityRoutesDeps,
} from './api/identity.routes.js';
export { createSessionAuthenticator } from './infrastructure/session-authenticator.js';
export { Argon2PasswordHasher } from './infrastructure/argon2-password-hasher.js';
export { SettingsPasswordResetMailer } from './infrastructure/settings-password-reset-mailer.js';
export { SettingsWelcomeMailer } from './infrastructure/settings-welcome-mailer.js';

View File

@@ -12,6 +12,9 @@ import { GetPosConfigUseCase } from '../application/get-pos-config.js';
import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
import { CreatePosSaleUseCase } from '../application/create-pos-sale.js';
import { buildPosReceipt } from '../application/build-pos-receipt.js';
import { sendTransactionalEmail } from '../../notifications/index.js';
import { Argon2PasswordHasher } from '../../identity/index.js';
import { PgStoreRepository } from '../infrastructure/pg-store-repository.js';
import { PgTerminalRepository } from '../infrastructure/pg-terminal-repository.js';
import { PgPaymentMethodRepository } from '../infrastructure/pg-payment-method-repository.js';
@@ -25,7 +28,31 @@ export interface PosRouteDeps {
const idParamSchema = z.object({ id: z.string().uuid() });
// Zod's strict UUID parser rejects the RFC nil UUID used by the seeded default
// store. Store references accept the canonical UUID shape, including nil.
const storeIdSchema = z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
const storeIdSchema = z
.string()
.regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
async function assertReceiptAccess(
pool: pg.Pool,
user: CurrentUser,
terminalId: string | string[] | undefined,
orderId: string,
): Promise<void> {
if (user.role === 'admin') return;
if (typeof terminalId !== 'string') {
throw new AppError(403, 'POS_TERMINAL_REQUIRED', 'El terminal vinculado es obligatorio');
}
const result = await pool.query<{ allowed: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM orders_orders
WHERE id = $1 AND source = 'pos' AND terminal_id = $2
) AS allowed`,
[orderId, terminalId],
);
if (!result.rows[0]?.allowed) {
throw new AppError(403, 'POS_RECEIPT_FORBIDDEN', 'El ticket pertenece a otro terminal');
}
}
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
const { pool, authenticate } = deps;
@@ -235,9 +262,12 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
properties: {
touchNavigationEnabled: { type: 'boolean' },
quickProductVariantIds: {
type: 'array', minItems: 4, maxItems: 4,
type: 'array',
minItems: 8,
maxItems: 8,
items: { type: ['string', 'null'], format: 'uuid' },
},
lineDiscountsEnabled: { type: 'boolean' },
},
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
@@ -250,13 +280,20 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const body = parseJson(
z.object({
touchNavigationEnabled: z.boolean(),
quickProductVariantIds: z.array(z.string().uuid().nullable()).length(4),
quickProductVariantIds: z.array(z.string().uuid().nullable()).length(8),
lineDiscountsEnabled: z.boolean().default(true),
}),
request.body ?? {},
);
const selected = body.quickProductVariantIds.filter((value): value is string => value !== null);
const selected = body.quickProductVariantIds.filter(
(value): value is string => value !== null,
);
if (new Set(selected).size !== selected.length) {
throw new AppError(400, 'DUPLICATE_QUICK_PRODUCT', 'Cada acceso rápido debe usar un producto diferente');
throw new AppError(
400,
'DUPLICATE_QUICK_PRODUCT',
'Cada acceso rápido debe usar un producto diferente',
);
}
if (selected.length > 0) {
const valid = await pool.query<{ count: string }>(
@@ -274,11 +311,17 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
SET interface_mode = CASE WHEN $2 THEN 'touch' ELSE 'auto' END,
settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object(
'touchNavigationEnabled', $2::boolean,
'quickProductVariantIds', $3::jsonb
'quickProductVariantIds', $3::jsonb,
'lineDiscountsEnabled', $4::boolean
),
updated_at = now()
WHERE id = $1 RETURNING *`,
[id, body.touchNavigationEnabled, JSON.stringify(body.quickProductVariantIds)],
[
id,
body.touchNavigationEnabled,
JSON.stringify(body.quickProductVariantIds),
body.lineDiscountsEnabled,
],
);
if (!result.rows[0]) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
return reply.send({ ok: true });
@@ -519,7 +562,8 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
tags: ['POS Terminal'],
summary: 'Get category navigation and quick products for the bound terminal',
headers: {
type: 'object', required: ['x-terminal-id'],
type: 'object',
required: ['x-terminal-id'],
properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } },
},
response: { 401: errorSchema, 404: errorSchema },
@@ -529,11 +573,14 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const terminalId = request.headers['x-terminal-id'] as string | undefined;
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
const terminalResult = await pool.query<{ store_id: string; settings: Record<string, unknown> }>(
`SELECT store_id, settings FROM pos_terminals WHERE id = $1 AND status = 'active'`,
[terminalId],
);
if (!terminalId)
throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
const terminalResult = await pool.query<{
store_id: string;
settings: Record<string, unknown>;
}>(`SELECT store_id, settings FROM pos_terminals WHERE id = $1 AND status = 'active'`, [
terminalId,
]);
const terminal = terminalResult.rows[0];
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
const [categoryResult, productResult] = await Promise.all([
@@ -560,18 +607,19 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
]);
const configured = Array.isArray(terminal.settings?.quickProductVariantIds)
? terminal.settings.quickProductVariantIds
: [null, null, null, null];
: Array.from({ length: 8 }, () => null);
const uniqueProducts = new Map<string, Record<string, unknown>>();
for (const row of productResult.rows as Array<Record<string, unknown>>) {
if (!uniqueProducts.has(String(row.variantId))) uniqueProducts.set(String(row.variantId), row);
if (!uniqueProducts.has(String(row.variantId)))
uniqueProducts.set(String(row.variantId), row);
}
return reply.send({
enabled: terminal.settings?.touchNavigationEnabled !== false,
categories: categoryResult.rows,
products: productResult.rows,
quickProducts: [0, 1, 2, 3].map((slot) => {
quickProducts: Array.from({ length: 8 }, (_, slot) => {
const variantId = configured[slot];
return typeof variantId === 'string' ? uniqueProducts.get(variantId) ?? null : null;
return typeof variantId === 'string' ? (uniqueProducts.get(variantId) ?? null) : null;
}),
});
},
@@ -750,9 +798,14 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { storeId } = request.query as { storeId: string };
const methods = await paymentMethodRepo.listByStore(storeId);
return reply.send({ items: methods });
const { storeId } = parseJson(z.object({ storeId: storeIdSchema }), request.query);
const methods = await pool.query(
`SELECT id, store_id AS "storeId", code, label, kind, active,
sort_order AS "sortOrder", config, created_at AS "createdAt", updated_at AS "updatedAt"
FROM pos_payment_methods WHERE store_id = $1 ORDER BY sort_order, label`,
[storeId],
);
return reply.send({ items: methods.rows });
},
);
@@ -782,8 +835,12 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
requireRole(user, 'admin');
const body = parseJson(
z.object({
storeId: z.string().uuid(),
code: z.string().min(1).max(32),
storeId: storeIdSchema,
code: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9_-]{1,32}$/),
label: z.string().min(1).max(64),
kind: z.enum(['cash', 'card', 'other']),
active: z.boolean().default(true),
@@ -855,6 +912,129 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
app.get(
'/pos/admin/receipt-settings',
{
schema: {
tags: ['POS Admin'],
summary: 'Get company and receipt numbering settings',
querystring: {
type: 'object',
required: ['storeId'],
properties: { storeId: { type: 'string', format: 'uuid' } },
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { storeId } = parseJson(z.object({ storeId: storeIdSchema }), request.query);
await pool.query(
`INSERT INTO pos_receipt_settings (store_id) VALUES ($1)
ON CONFLICT (store_id) DO NOTHING`,
[storeId],
);
const result = await pool.query(
`SELECT store.id AS "storeId", store.name, store.address, store.tax_id AS "taxId",
store.contact_email AS "contactEmail", store.contact_phone AS "contactPhone",
store.receipt_header AS "receiptHeader", store.receipt_footer AS "receiptFooter",
receipt.prefix, receipt.next_number::float8 AS "nextNumber", receipt.padding,
receipt.return_policy AS "returnPolicy"
FROM pos_stores store
JOIN pos_receipt_settings receipt ON receipt.store_id = store.id
WHERE store.id = $1`,
[storeId],
);
if (!result.rows[0]) throw new AppError(404, 'STORE_NOT_FOUND', 'No se encontró la tienda');
return reply.send(result.rows[0]);
},
);
app.patch(
'/pos/admin/receipt-settings',
{
schema: {
tags: ['POS Admin'],
summary: 'Update company and receipt numbering settings',
body: {
type: 'object',
required: ['storeId'],
properties: { storeId: { type: 'string', format: 'uuid' } },
},
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const body = parseJson(
z.object({
storeId: storeIdSchema,
name: z.string().trim().min(1).max(200).optional(),
address: z.string().trim().max(500).optional(),
taxId: z.string().trim().max(64).optional(),
contactEmail: z.union([z.string().trim().email(), z.literal('')]).optional(),
contactPhone: z.string().trim().max(64).optional(),
receiptHeader: z.string().trim().max(500).optional(),
receiptFooter: z.string().trim().max(1000).optional(),
prefix: z
.string()
.trim()
.regex(/^[A-Za-z0-9_-]{1,16}$/)
.optional(),
nextNumber: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional(),
padding: z.number().int().min(1).max(12).optional(),
returnPolicy: z.string().trim().min(1).max(2000).optional(),
}),
request.body ?? {},
);
const client = await pool.connect();
try {
await client.query('BEGIN');
const store = await client.query(
`UPDATE pos_stores
SET name = COALESCE($2, name), address = COALESCE($3, address),
tax_id = COALESCE($4, tax_id), contact_email = COALESCE($5, contact_email),
contact_phone = COALESCE($6, contact_phone),
receipt_header = COALESCE($7, receipt_header),
receipt_footer = COALESCE($8, receipt_footer), updated_at = now()
WHERE id = $1 RETURNING id`,
[
body.storeId,
body.name,
body.address,
body.taxId,
body.contactEmail,
body.contactPhone,
body.receiptHeader,
body.receiptFooter,
],
);
if (!store.rows[0]) throw new AppError(404, 'STORE_NOT_FOUND', 'No se encontró la tienda');
await client.query(
`INSERT INTO pos_receipt_settings (store_id, prefix, next_number, padding, return_policy)
VALUES ($1, COALESCE($2, 'TPV'), COALESCE($3, 1), COALESCE($4, 6),
COALESCE($5, 'Se admiten devoluciones durante 15 días con el ticket de compra.'))
ON CONFLICT (store_id) DO UPDATE
SET prefix = COALESCE($2, pos_receipt_settings.prefix),
next_number = COALESCE($3, pos_receipt_settings.next_number),
padding = COALESCE($4, pos_receipt_settings.padding),
return_policy = COALESCE($5, pos_receipt_settings.return_policy),
updated_at = now()`,
[body.storeId, body.prefix, body.nextNumber, body.padding, body.returnPolicy],
);
await client.query('COMMIT');
return reply.send({ ok: true });
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
},
);
// ── POS-008: POST /pos/sales idempotent ───────────────────────────────────
app.post(
@@ -862,7 +1042,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
{
schema: {
tags: ['POS Terminal'],
summary: 'Record a POS sale (idempotent)',
summary: 'Confirm a POS sale with complete payment allocation',
body: {
type: 'object',
required: ['idempotencyKey', 'cashSessionId', 'terminalId', 'items', 'payments'],
@@ -870,86 +1050,71 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
idempotencyKey: { type: 'string', minLength: 1, maxLength: 128 },
cashSessionId: { type: 'string', format: 'uuid' },
terminalId: { type: 'string', format: 'uuid' },
items: {
type: 'array',
items: {
type: 'object',
required: [
'variantId',
'productId',
'sku',
'name',
'unitPriceCents',
'discountCents',
'taxCents',
'quantity',
],
properties: {
variantId: { type: 'string', format: 'uuid' },
productId: { type: 'string', format: 'uuid' },
sku: { type: 'string' },
ean: { type: ['string', 'null'] },
name: { type: 'string' },
unitPriceCents: { type: 'integer', minimum: 0 },
discountCents: { type: 'integer', minimum: 0 },
taxCents: { type: 'integer', minimum: 0 },
quantity: { type: 'integer', minimum: 1 },
},
},
},
payments: {
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['kind', 'amountCents'],
properties: {
kind: { type: 'string', enum: ['cash', 'card', 'other'] },
amountCents: { type: 'integer', minimum: 1 },
tenderedCents: { type: 'integer', minimum: 0 },
last4: { type: 'string', maxLength: 4 },
},
},
},
items: { type: 'array', minItems: 1, items: { type: 'object' } },
payments: { type: 'array', minItems: 1, items: { type: 'object' } },
customerId: { type: 'string', format: 'uuid' },
},
},
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
response: {
400: errorSchema,
401: errorSchema,
403: errorSchema,
404: errorSchema,
409: errorSchema,
},
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const stockLine = z.object({
kind: z.literal('stock').optional(),
variantId: z.string().uuid(),
quantity: z.number().int().min(1),
discountCents: z.number().int().min(0).optional(),
});
const freeLine = z.object({
kind: z.literal('free'),
name: z.string().trim().min(1).max(200),
unitPriceCents: z.number().int().min(1),
quantity: z.number().int().min(1).max(999),
});
const payment = z
.object({
methodCode: z
.string()
.regex(/^[a-z0-9_-]{1,32}$/)
.optional(),
kind: z.enum(['cash', 'card', 'other']).optional(),
amountCents: z.number().int().min(1),
tenderedCents: z.number().int().min(0).optional(),
last4: z
.string()
.regex(/^\d{1,4}$/)
.optional(),
})
.refine((value) => Boolean(value.methodCode || value.kind), {
message: 'methodCode is required',
});
const body = parseJson(
z.object({
idempotencyKey: z.string().min(1).max(128),
cashSessionId: z.string().uuid(),
terminalId: z.string().uuid(),
items: z.array(
z.object({
variantId: z.string().uuid(),
productId: z.string().uuid(),
sku: z.string(),
ean: z.string().nullable(),
name: z.string(),
unitPriceCents: z.number().int().min(0),
discountCents: z.number().int().min(0),
taxCents: z.number().int().min(0),
quantity: z.number().int().min(1),
}),
),
payments: z.array(
z.object({
kind: z.enum(['cash', 'card', 'other']),
amountCents: z.number().int().min(1),
tenderedCents: z.number().int().min(0).optional(),
last4: z.string().max(4).optional(),
}),
),
items: z.array(z.union([freeLine, stockLine])).min(1),
payments: z.array(payment).min(1),
customerId: z.string().uuid().optional(),
}),
request.body ?? {},
);
const boundTerminalId = request.headers['x-terminal-id'];
if (typeof boundTerminalId !== 'string' || boundTerminalId !== body.terminalId) {
throw new AppError(
403,
'POS_TERMINAL_MISMATCH',
'La venta no pertenece al terminal vinculado',
);
}
const result = await createPosSale.execute({ ...body, userId: user.id });
return reply.code(201).send(result);
},
@@ -1014,16 +1179,22 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const body = parseJson(
z.object({
email: z.string().trim().email().transform((value) => value.toLowerCase()),
email: z
.string()
.trim()
.email()
.transform((value) => value.toLowerCase()),
displayName: z.string().trim().min(1).max(160).optional(),
phone: z.string().trim().min(1).max(40).optional(),
}),
request.body ?? {},
);
const existing = await pool.query('SELECT id FROM identity_users WHERE lower(email) = $1', [body.email]);
if (existing.rows[0]) throw new AppError(409, 'CUSTOMER_EXISTS', 'Ya existe un cliente con ese email');
const existing = await pool.query('SELECT id FROM identity_users WHERE lower(email) = $1', [
body.email,
]);
if (existing.rows[0])
throw new AppError(409, 'CUSTOMER_EXISTS', 'Ya existe un cliente con ese email');
const { randomBytes } = await import('node:crypto');
const { Argon2PasswordHasher } = await import('../../identity/infrastructure/argon2-password-hasher.js');
const passwordHash = await new Argon2PasswordHasher().hash(randomBytes(32).toString('hex'));
const client = await pool.connect();
try {
@@ -1034,7 +1205,8 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
[body.email, passwordHash, body.phone ?? null],
);
const customer = created.rows[0];
if (!customer) throw new AppError(500, 'CUSTOMER_CREATE_FAILED', 'No se pudo crear el cliente');
if (!customer)
throw new AppError(500, 'CUSTOMER_CREATE_FAILED', 'No se pudo crear el cliente');
await client.query(
`INSERT INTO users_profiles (user_id, display_name, phone)
VALUES ($1, $2, $3)
@@ -1042,7 +1214,9 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
[customer.id, body.displayName ?? null, body.phone ?? null],
);
await client.query('COMMIT');
return reply.code(201).send({ ...customer, displayName: body.displayName ?? null, phone: body.phone ?? null });
return reply
.code(201)
.send({ ...customer, displayName: body.displayName ?? null, phone: body.phone ?? null });
} catch (error) {
await client.query('ROLLBACK');
throw error;
@@ -1221,22 +1395,9 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { id } = request.params;
const order = await pool.query(
`SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id WHERE o.id = $1`,
[id],
);
if (!order.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Order not found');
const items = await pool.query('SELECT * FROM orders_items WHERE order_id = $1', [id]);
const payments = await pool.query<{ amount_cents: number; provider: string }>(
'SELECT amount_cents, provider FROM payments_transactions WHERE order_id = $1',
[id],
);
return reply.send({
order: order.rows[0],
items: items.rows,
payments: payments.rows,
});
const { id } = parseJson(idParamSchema, request.params);
await assertReceiptAccess(pool, user, request.headers['x-terminal-id'], id);
return reply.send({ receipt: await buildPosReceipt(pool, id) });
},
);
@@ -1355,35 +1516,74 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { id } = request.params;
const order = await pool.query('SELECT * FROM orders_orders WHERE id = $1', [id]);
if (!order.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Sale not found');
const items = await pool.query(
'SELECT name, quantity, unit_price_cents, discount_cents, tax_cents FROM orders_items WHERE order_id = $1',
[id],
);
const payments = await pool.query<{ amount_cents: number; provider: string }>(
'SELECT amount_cents, provider FROM payments_transactions WHERE order_id = $1',
[id],
);
return reply.send({
receipt: {
orderId: id,
storeName: 'Mercado de Vida',
terminalName: 'TPV',
totalCents: order.rows[0].total_cents,
createdAt: order.rows[0].created_at,
items: items.rows.map((i) => ({
name: i.name,
qty: i.quantity,
unitPrice: i.unit_price_cents,
discount: i.discount_cents,
tax: i.tax_cents,
line: (i.unit_price_cents - i.discount_cents + i.tax_cents) * i.quantity,
})),
payments: payments.rows.map((p) => ({ amountCents: p.amount_cents, kind: p.provider })),
const { id } = parseJson(idParamSchema, request.params);
await assertReceiptAccess(pool, user, request.headers['x-terminal-id'], id);
return reply.send({ receipt: await buildPosReceipt(pool, id) });
},
);
app.post<{ Params: { id: string } }>(
'/pos/sales/:id/receipt/email',
{
schema: {
tags: ['POS Terminal'],
summary: 'Email a generated POS receipt',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['email'],
properties: { email: { type: 'string', format: 'email', maxLength: 254 } },
},
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { id } = parseJson(idParamSchema, request.params);
const { email } = parseJson(
z.object({ email: z.string().trim().email().max(254) }),
request.body ?? {},
);
await assertReceiptAccess(pool, user, request.headers['x-terminal-id'], id);
const receipt = await buildPosReceipt(pool, id);
const euros = (cents: number) =>
new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(cents / 100);
const text = [
receipt.company.name,
receipt.company.address ?? '',
receipt.company.taxId ? `NIF/CIF: ${receipt.company.taxId}` : '',
receipt.company.phone ?? '',
receipt.company.email ?? '',
'',
`Ticket ${receipt.receiptNumber}`,
new Date(receipt.issuedAt).toLocaleString('es-ES'),
'',
...receipt.items.map(
(item) => `${item.quantity} × ${item.name}${euros(item.totalCents)}`,
),
'',
`Subtotal: ${euros(receipt.subtotalCents)}`,
`Descuentos: ${euros(receipt.discountCents)}`,
`IVA: ${euros(receipt.taxCents)}`,
`TOTAL: ${euros(receipt.totalCents)}`,
'',
...receipt.payments.map(
(payment) =>
`${payment.methodLabel}: ${euros(payment.amountCents)}${payment.changeCents ? ` · cambio ${euros(payment.changeCents)}` : ''}`,
),
'',
receipt.returnPolicy,
receipt.footer ?? '',
]
.filter((line, index, lines) => line !== '' || lines[index - 1] !== '')
.join('\n');
await sendTransactionalEmail(pool, {
to: email,
subject: `Ticket ${receipt.receiptNumber} · ${receipt.company.name}`,
text,
});
return reply.send({ ok: true, email, receiptNumber: receipt.receiptNumber });
},
);
@@ -1989,9 +2189,9 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { storeId } = request.query as { storeId?: string };
const result = await pool.query(
`SELECT u.id, u.email, u.role, p.first_name AS "firstName", p.last_name AS "lastName" FROM identity_users u LEFT JOIN users_profiles p ON p.user_id = u.id WHERE u.role IN ('pos_manager','pos_cashier') ORDER BY u.email`,
`SELECT id, email, role FROM backoffice_users
WHERE role IN ('pos_manager','pos_cashier') ORDER BY email`,
);
return reply.send({ items: result.rows });
},
@@ -2020,38 +2220,33 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const body = (request.body ?? {}) as {
email?: string;
password?: string;
role?: string;
firstName?: string;
lastName?: string;
};
// Delegate to identity module (simple insert for now)
const existing = await pool.query('SELECT id FROM identity_users WHERE email = $1', [
const body = parseJson(
z.object({
email: z
.string()
.trim()
.email()
.transform((value) => value.toLowerCase()),
password: z.string().min(8).max(200),
role: z.enum(['pos_manager', 'pos_cashier']),
firstName: z.string().trim().max(100).optional(),
lastName: z.string().trim().max(100).optional(),
}),
request.body ?? {},
);
const existing = await pool.query('SELECT id FROM backoffice_users WHERE email = $1', [
body.email,
]);
if (existing.rows[0]) throw new AppError(409, 'EXISTS', 'Email already exists');
const { hash } = await import('crypto').then((c) => ({ hash: c.default?.webcrypto ?? null }));
const hashSync = (pwd: string) => {
const h = require('crypto').createHash('sha256');
h.update(pwd);
return h.digest('hex');
};
const pwdHash = hashSync(body.password ?? '');
const passwordHash = await new Argon2PasswordHasher().hash(body.password);
const newUser = await pool.query<{ id: string }>(
`INSERT INTO identity_users (email, password_hash, role, created_at) VALUES ($1, $2, $3, now()) RETURNING id`,
[body.email, pwdHash, body.role],
`INSERT INTO backoffice_users (email, password_hash, role)
VALUES ($1, $2, $3) RETURNING id`,
[body.email, passwordHash, body.role],
);
const nu = newUser.rows[0];
if (!nu) throw new AppError(500, 'USER_CREATE_FAILED', 'User insert returned no row');
if (body.firstName || body.lastName) {
await pool.query(
`INSERT INTO users_profiles (user_id, first_name, last_name) VALUES ($1, $2, $3)`,
[nu.id, body.firstName ?? null, body.lastName ?? null],
);
}
return reply.code(201).send({ id: nu.id, email: body.email, role: body.role });
const created = newUser.rows[0];
if (!created) throw new AppError(500, 'USER_CREATE_FAILED', 'User insert returned no row');
return reply.code(201).send({ id: created.id, email: body.email, role: body.role });
},
);
@@ -2077,7 +2272,6 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const user = await authenticate(request);
requireRole(user, 'admin');
const {
storeId,
from,
to,
limit = 50,

View File

@@ -0,0 +1,167 @@
import type pg from 'pg';
import type { PosPaymentKind, PosReceipt, PosReceiptPayment } from '../domain/pos-sale.js';
import { AppError } from '../../../shared/errors.js';
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
interface ReceiptOrderRow {
id: string;
receipt_number: string | null;
subtotal_cents: number;
discount_cents: number;
tax_cents: number;
total_cents: number;
created_at: Date;
cash_session_id: string;
terminal_id: string;
customer_email: string | null;
store_name: string;
address: string | null;
tax_id: string | null;
contact_email: string | null;
contact_phone: string | null;
receipt_header: string | null;
receipt_footer: string | null;
terminal_name: string;
cashier_email: string;
return_policy: string | null;
}
interface ReceiptItemRow {
name: string;
sku: string;
quantity: number;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
is_free_item: boolean;
}
interface ReceiptPaymentRow {
amount_cents: number;
provider: string;
raw: Record<string, unknown> | null;
}
export async function buildPosReceipt(queryable: Queryable, orderId: string): Promise<PosReceipt> {
const orderResult = await queryable.query<ReceiptOrderRow>(
`SELECT o.id, o.receipt_number, o.subtotal_cents, o.discount_cents, o.tax_cents,
o.total_cents, o.created_at, o.cash_session_id, o.terminal_id,
customer.email AS customer_email,
store.name AS store_name, store.address, store.tax_id, store.contact_email,
store.contact_phone, store.receipt_header, store.receipt_footer,
terminal.name AS terminal_name, cashier.email AS cashier_email,
receipt_settings.return_policy
FROM orders_orders o
JOIN pos_stores store ON store.id = o.store_id
JOIN pos_terminals terminal ON terminal.id = o.terminal_id
JOIN pos_cash_sessions session ON session.id = o.cash_session_id
JOIN backoffice_users cashier ON cashier.id = session.user_id
LEFT JOIN identity_users customer ON customer.id = o.user_id
LEFT JOIN pos_receipt_settings receipt_settings ON receipt_settings.store_id = o.store_id
WHERE o.id = $1 AND o.source = 'pos'`,
[orderId],
);
const order = orderResult.rows[0];
if (!order) throw new AppError(404, 'POS_RECEIPT_NOT_FOUND', 'No se encontró el ticket');
const [itemResult, paymentResult] = await Promise.all([
queryable.query<ReceiptItemRow>(
`SELECT name, sku, quantity, unit_price_cents, discount_cents, tax_cents, is_free_item
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[orderId],
),
queryable.query<ReceiptPaymentRow>(
`SELECT amount_cents, provider, raw
FROM payments_transactions WHERE order_id = $1 AND status = 'succeeded'
ORDER BY created_at, id`,
[orderId],
),
]);
const payments: PosReceiptPayment[] = paymentResult.rows.map((row) => {
const raw = row.raw ?? {};
const kind = isPaymentKind(raw.kind) ? raw.kind : providerKind(row.provider);
const tendered = integerOrNull(raw.tenderedCents);
const change = integerOrZero(raw.changeCents);
return {
methodCode: stringOr(raw.methodCode, row.provider.replace(/^pos_/, '')),
methodLabel: stringOr(raw.methodLabel, paymentKindLabel(kind)),
kind,
amountCents: Number(row.amount_cents),
tenderedCents: tendered,
changeCents: change,
};
});
return {
receiptNumber: order.receipt_number ?? `POS-${order.id.slice(0, 8).toUpperCase()}`,
orderId: order.id,
issuedAt: order.created_at,
company: {
name: order.store_name,
address: order.address,
taxId: order.tax_id,
email: order.contact_email,
phone: order.contact_phone,
},
terminal: { id: order.terminal_id, name: order.terminal_name },
cashier: order.cashier_email,
sessionId: order.cash_session_id,
customerEmail: order.customer_email,
items: itemResult.rows.map((item) => {
const subtotalCents = Number(item.unit_price_cents) * Number(item.quantity);
const discountCents = Number(item.discount_cents) * Number(item.quantity);
const taxCents = Number(item.tax_cents) * Number(item.quantity);
return {
name: item.name,
sku: item.sku,
quantity: Number(item.quantity),
unitPriceCents: Number(item.unit_price_cents),
subtotalCents,
discountCents,
taxCents,
totalCents: subtotalCents - discountCents + taxCents,
freeItem: item.is_free_item,
};
}),
subtotalCents: Number(order.subtotal_cents),
discountCents: Number(order.discount_cents),
taxCents: Number(order.tax_cents),
totalCents: Number(order.total_cents),
payments,
changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0),
header: order.receipt_header,
returnPolicy:
order.return_policy ?? 'Se admiten devoluciones durante 15 días con el ticket de compra.',
footer: order.receipt_footer,
};
}
function isPaymentKind(value: unknown): value is PosPaymentKind {
return value === 'cash' || value === 'card' || value === 'other';
}
function providerKind(provider: string): PosPaymentKind {
if (provider === 'pos_cash') return 'cash';
if (provider === 'pos_card') return 'card';
return 'other';
}
function paymentKindLabel(kind: PosPaymentKind): string {
if (kind === 'cash') return 'Efectivo';
if (kind === 'card') return 'Tarjeta';
return 'Otro';
}
function stringOr(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() ? value : fallback;
}
function integerOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) ? value : null;
}
function integerOrZero(value: unknown): number {
return typeof value === 'number' && Number.isInteger(value) ? value : 0;
}

View File

@@ -1,20 +1,119 @@
import type pg from 'pg';
import type { PosSaleInput, PosSaleResult, PosPaymentResult } from '../domain/pos-sale.js';
import type {
PosPaymentInput,
PosPaymentKind,
PosPaymentResult,
PosSaleInput,
PosSaleLineItem,
PosSaleResult,
} from '../domain/pos-sale.js';
import { AppError } from '../../../shared/errors.js';
import { buildPosReceipt } from './build-pos-receipt.js';
interface PaymentRow { id: string; amount_cents: number; provider: string; }
interface OrderItemRow {
interface SessionRow {
id: string;
terminal_id: string;
store_id: string;
status: string;
terminal_settings: Record<string, unknown>;
}
interface CatalogLineRow {
variant_id: string;
product_id: string;
sku: string;
ean: string | null;
name: string;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
quantity: number;
vat_rate: string;
}
export interface ConfiguredPaymentMethod {
id: string;
code: string;
label: string;
kind: PosPaymentKind;
}
export interface ValidatedPayment {
methodId: string;
methodCode: string;
methodLabel: string;
kind: PosPaymentKind;
amountCents: number;
tenderedCents: number | null;
changeCents: number;
last4?: string;
}
export function validatePaymentAllocations(
totalCents: number,
inputs: PosPaymentInput[],
methods: ConfiguredPaymentMethod[],
): ValidatedPayment[] {
if (inputs.length === 0) {
throw new AppError(400, 'POS_PAYMENT_REQUIRED', 'Selecciona al menos una forma de pago');
}
const byCode = new Map(methods.map((method) => [method.code, method]));
const validated = inputs.map((input) => {
const methodCode = input.methodCode ?? input.kind;
const method = methodCode ? byCode.get(methodCode) : undefined;
if (!method) {
throw new AppError(400, 'POS_PAYMENT_METHOD_DISABLED', 'La forma de pago no está habilitada');
}
if (!Number.isInteger(input.amountCents) || input.amountCents <= 0) {
throw new AppError(
400,
'POS_PAYMENT_INVALID_AMOUNT',
'El importe del pago debe ser positivo',
);
}
if (method.kind === 'cash') {
const tenderedCents = input.tenderedCents ?? input.amountCents;
if (!Number.isInteger(tenderedCents) || tenderedCents < input.amountCents) {
throw new AppError(
400,
'POS_CASH_TENDERED_TOO_LOW',
'El efectivo entregado no cubre el importe aplicado',
);
}
return {
methodId: method.id,
methodCode: method.code,
methodLabel: method.label,
kind: method.kind,
amountCents: input.amountCents,
tenderedCents,
changeCents: tenderedCents - input.amountCents,
...(input.last4 ? { last4: input.last4 } : {}),
};
}
if (input.tenderedCents !== undefined) {
throw new AppError(400, 'POS_NON_CASH_TENDERED', 'Solo el efectivo admite importe entregado');
}
return {
methodId: method.id,
methodCode: method.code,
methodLabel: method.label,
kind: method.kind,
amountCents: input.amountCents,
tenderedCents: null,
changeCents: 0,
...(input.last4 ? { last4: input.last4 } : {}),
};
});
const allocatedCents = validated.reduce((sum, payment) => sum + payment.amountCents, 0);
if (allocatedCents !== totalCents) {
throw new AppError(
400,
'POS_PAYMENT_TOTAL_MISMATCH',
allocatedCents < totalCents
? 'Falta importe por asignar antes de confirmar'
: 'Los pagos asignados superan el total',
);
}
return validated;
}
interface OrderRow { id: string; idempotency_key: string; total_cents: number; created_at: Date; }
export class CreatePosSaleUseCase {
constructor(private readonly pool: pg.Pool) {}
@@ -24,82 +123,175 @@ export class CreatePosSaleUseCase {
try {
await client.query('BEGIN');
// 1. Idempotency: return existing sale if key already used
const existing = await client.query<{ id: string }>(
'SELECT id FROM orders_orders WHERE idempotency_key = $1',
`SELECT id FROM orders_orders WHERE idempotency_key = $1 AND source = 'pos'`,
[input.idempotencyKey],
);
if (existing.rows[0]) {
const existId = existing.rows[0].id;
const orderId = existing.rows[0].id;
await client.query('ROLLBACK');
const order = await this.pool.query<OrderRow>(
'SELECT id, idempotency_key, total_cents, created_at FROM orders_orders WHERE idempotency_key = $1',
[input.idempotencyKey],
);
const items = await this.pool.query<OrderItemRow>(
`SELECT variant_id, product_id, sku, ean, name, unit_price_cents,
discount_cents, tax_cents, quantity
FROM orders_items WHERE order_id = $1`,
[existId],
);
const payments = await this.pool.query<PaymentRow>(
'SELECT id, amount_cents, provider FROM payments_transactions WHERE order_id = $1',
[existId],
);
const totalCents = (order.rows[0]?.total_cents ?? 0) as number;
const mappedItems: PosSaleResult['items'] = items.rows.map((row) => ({
variantId: row.variant_id,
productId: row.product_id,
sku: row.sku,
ean: row.ean,
name: row.name,
unitPriceCents: row.unit_price_cents,
discountCents: row.discount_cents,
taxCents: row.tax_cents,
quantity: row.quantity,
}));
return {
orderId: existId,
idempotencyKey: input.idempotencyKey,
totalCents,
items: mappedItems,
payments: payments.rows.map((row) => ({
id: row.id,
kind: providerToPaymentKind(row.provider),
amountCents: row.amount_cents,
})),
createdAt: order.rows[0]?.created_at ?? new Date(),
};
return this.loadResult(orderId, input.idempotencyKey);
}
// 2. Verify cash session is open (FOR UPDATE lock)
const session = await client.query<{ id: string; terminal_id: string; store_id: string; status: string }>(
`SELECT id, terminal_id, store_id, status FROM pos_cash_sessions WHERE id = $1 FOR UPDATE`,
const sessionResult = await client.query<SessionRow>(
`SELECT session.id, session.terminal_id, session.store_id, session.status,
terminal.settings AS terminal_settings
FROM pos_cash_sessions session
JOIN pos_terminals terminal ON terminal.id = session.terminal_id
WHERE session.id = $1
FOR UPDATE OF session, terminal`,
[input.cashSessionId],
);
const sessionRow = session.rows[0];
if (!sessionRow) { await client.query('ROLLBACK'); throw new AppError(404, 'SESSION_NOT_FOUND', 'Cash session not found'); }
if (sessionRow.status !== 'OPEN') { await client.query('ROLLBACK'); throw new AppError(409, 'SESSION_CLOSED', 'Cash session is not open'); }
if (sessionRow.terminal_id !== input.terminalId) {
await client.query('ROLLBACK');
throw new AppError(409, 'TERMINAL_SESSION_MISMATCH', 'Cash session belongs to another terminal');
const session = sessionResult.rows[0];
if (!session)
throw new AppError(404, 'SESSION_NOT_FOUND', 'No se encontró la sesión de caja');
if (session.status !== 'OPEN')
throw new AppError(409, 'SESSION_CLOSED', 'La sesión de caja está cerrada');
if (session.terminal_id !== input.terminalId) {
throw new AppError(409, 'TERMINAL_SESSION_MISMATCH', 'La sesión pertenece a otro terminal');
}
if (input.items.length === 0)
throw new AppError(400, 'POS_EMPTY_CART', 'El carrito está vacío');
const lineDiscountsEnabled = session.terminal_settings?.lineDiscountsEnabled !== false;
const items: PosSaleLineItem[] = [];
for (const inputItem of input.items) {
if (inputItem.kind === 'free') {
const name = inputItem.name.trim();
if (
!name ||
!Number.isInteger(inputItem.unitPriceCents) ||
inputItem.unitPriceCents <= 0
) {
throw new AppError(
400,
'POS_INVALID_FREE_ITEM',
'El artículo libre requiere nombre y precio positivo',
);
}
items.push({
kind: 'free',
variantId: null,
productId: null,
sku: 'LIBRE',
ean: null,
name,
unitPriceCents: inputItem.unitPriceCents,
discountCents: 0,
taxCents: 0,
quantity: inputItem.quantity,
vatRate: null,
});
continue;
}
const catalogResult = await client.query<CatalogLineRow>(
`SELECT variant.id AS variant_id, variant.product_id, variant.sku, variant.ean,
product.name,
COALESCE(price.offer_cents, price.net_unit_amount_cents) AS unit_price_cents,
price.vat_rate
FROM catalog_product_variants variant
JOIN catalog_products product ON product.id = variant.product_id
JOIN pricing_variant_prices price ON price.variant_id = variant.id AND price.currency = 'EUR'
WHERE variant.id = $1 AND product.state = 'active'`,
[inputItem.variantId],
);
const catalog = catalogResult.rows[0];
if (!catalog)
throw new AppError(404, 'POS_PRODUCT_NOT_FOUND', 'El producto ya no está disponible');
const discountCents = inputItem.discountCents ?? 0;
if (
!Number.isInteger(discountCents) ||
discountCents < 0 ||
discountCents > Number(catalog.unit_price_cents)
) {
throw new AppError(400, 'POS_INVALID_DISCOUNT', 'El descuento de línea no es válido');
}
if (!lineDiscountsEnabled && discountCents > 0) {
throw new AppError(
403,
'POS_DISCOUNTS_DISABLED',
'Los descuentos están desactivados en este terminal',
);
}
const stock = await client.query<{ available: number }>(
`SELECT available FROM inventory_stock
WHERE variant_id = $1 AND store_id = $2
FOR UPDATE`,
[catalog.variant_id, session.store_id],
);
if (Number(stock.rows[0]?.available ?? 0) < inputItem.quantity) {
throw new AppError(
409,
'POS_STOCK_UNAVAILABLE',
`Stock insuficiente para ${catalog.name}`,
);
}
items.push({
kind: 'stock',
variantId: catalog.variant_id,
productId: catalog.product_id,
sku: catalog.sku,
ean: catalog.ean,
name: catalog.name,
unitPriceCents: Number(catalog.unit_price_cents),
discountCents,
taxCents: 0,
quantity: inputItem.quantity,
vatRate: catalog.vat_rate,
});
}
// 3. Compute totals
const subtotalCents = input.items.reduce((s, i) => s + (i.unitPriceCents - i.discountCents) * i.quantity, 0);
const taxCents = input.items.reduce((s, i) => s + i.taxCents * i.quantity, 0);
const discountCents = input.items.reduce((s, i) => s + i.discountCents * i.quantity, 0);
const totalCents = subtotalCents + taxCents;
const paymentTotal = input.payments.reduce((s, p) => s + p.amountCents, 0);
if (paymentTotal < totalCents) { await client.query('ROLLBACK'); throw new AppError(400, 'INSUFFICIENT_PAYMENT', 'Payments less than total'); }
const subtotalCents = items.reduce(
(sum, item) => sum + item.unitPriceCents * item.quantity,
0,
);
const discountCents = items.reduce(
(sum, item) => sum + item.discountCents * item.quantity,
0,
);
const taxCents = items.reduce((sum, item) => sum + item.taxCents * item.quantity, 0);
const totalCents = subtotalCents - discountCents + taxCents;
if (totalCents <= 0)
throw new AppError(400, 'POS_INVALID_TOTAL', 'El total de la venta debe ser positivo');
// 4. Create order
const orderResult = await client.query<OrderRow>(
const methodsResult = await client.query<ConfiguredPaymentMethod>(
`SELECT id, code, label, kind
FROM pos_payment_methods
WHERE store_id = $1 AND active = true
ORDER BY sort_order, label`,
[session.store_id],
);
const payments = validatePaymentAllocations(totalCents, input.payments, methodsResult.rows);
await client.query(
`INSERT INTO pos_receipt_settings (store_id) VALUES ($1)
ON CONFLICT (store_id) DO NOTHING`,
[session.store_id],
);
const receiptSettings = await client.query<{
prefix: string;
next_number: string;
padding: number;
}>(
`SELECT prefix, next_number, padding FROM pos_receipt_settings WHERE store_id = $1 FOR UPDATE`,
[session.store_id],
);
const sequence = receiptSettings.rows[0];
if (!sequence)
throw new AppError(500, 'POS_RECEIPT_CONFIG_MISSING', 'Falta la configuración de tickets');
const receiptNumber = `${sequence.prefix}-${String(sequence.next_number).padStart(sequence.padding, '0')}`;
await client.query(
`UPDATE pos_receipt_settings SET next_number = next_number + 1, updated_at = now() WHERE store_id = $1`,
[session.store_id],
);
const orderResult = await client.query<{ id: string; created_at: Date }>(
`INSERT INTO orders_orders (
user_id, idempotency_key, subtotal_cents, discount_cents, tax_cents, total_cents,
source, terminal_id, cash_session_id, store_id
) VALUES ($1, $2, $3, $4, $5, $6, 'pos', $7, $8, $9)
RETURNING id, idempotency_key, total_cents, created_at`,
user_id, idempotency_key, state, subtotal_cents, discount_cents, tax_cents,
total_cents, source, terminal_id, cash_session_id, store_id, receipt_number
) VALUES ($1, $2, 'COMPLETED', $3, $4, $5, $6, 'pos', $7, $8, $9, $10)
RETURNING id, created_at`,
[
input.customerId ?? null,
input.idempotencyKey,
@@ -109,65 +301,174 @@ export class CreatePosSaleUseCase {
totalCents,
input.terminalId,
input.cashSessionId,
sessionRow.store_id,
session.store_id,
receiptNumber,
],
);
const orderRow = orderResult.rows[0];
if (!orderRow) { await client.query('ROLLBACK'); throw new AppError(500, 'ORDER_CREATE_FAILED', 'Order insert returned no row'); }
const orderId = orderRow.id;
const order = orderResult.rows[0];
if (!order) throw new AppError(500, 'ORDER_CREATE_FAILED', 'No se pudo crear la venta');
// 5. Insert line items
for (const item of input.items) {
for (const item of items) {
await client.query(
`INSERT INTO orders_items (order_id, product_id, variant_id, sku, ean, name, unit_price_cents, discount_cents, tax_cents, quantity)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[orderId, item.productId, item.variantId, item.sku, item.ean, item.name, item.unitPriceCents, item.discountCents, item.taxCents, item.quantity],
`INSERT INTO orders_items (
order_id, product_id, variant_id, sku, ean, name, unit_price_cents,
discount_cents, tax_cents, quantity, vat_rate, is_free_item
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
order.id,
item.productId,
item.variantId,
item.sku,
item.ean,
item.name,
item.unitPriceCents,
item.discountCents,
item.taxCents,
item.quantity,
item.vatRate,
item.kind === 'free',
],
);
if (item.kind === 'stock') {
await client.query(
`UPDATE inventory_stock
SET available = available - $3, sold = sold + $3, updated_at = now()
WHERE variant_id = $1 AND store_id = $2`,
[item.variantId, session.store_id, item.quantity],
);
await client.query(
`INSERT INTO inventory_movements (variant_id, store_id, operation, quantity)
VALUES ($1, $2, 'confirm', $3)`,
[item.variantId, session.store_id, item.quantity],
);
}
}
// 6. Process payments + update cash session
const paymentResults: PosPaymentResult[] = [];
let cashPaymentCents = 0;
for (const p of input.payments) {
const kind = p.kind === 'cash' ? 'pos_cash' : p.kind === 'card' ? 'pos_card' : 'pos_other';
const payResult = await client.query<{ id: string }>(
`INSERT INTO payments_transactions (provider, provider_event_id, provider_payment_id, order_id, amount_cents, currency, status, raw)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[kind, `pos-${orderId}-${p.kind}`, `pos-${Date.now()}`, orderId, p.amountCents, 'EUR', 'succeeded', JSON.stringify(p)],
let cashAppliedCents = 0;
for (const [index, payment] of payments.entries()) {
const raw = {
methodCode: payment.methodCode,
methodLabel: payment.methodLabel,
kind: payment.kind,
tenderedCents: payment.tenderedCents,
changeCents: payment.changeCents,
...(payment.last4 ? { last4: payment.last4 } : {}),
};
const inserted = await client.query<{ id: string }>(
`INSERT INTO payments_transactions (
provider, provider_event_id, provider_payment_id, order_id,
amount_cents, currency, status, raw
) VALUES ($1, $2, $3, $4, $5, 'EUR', 'succeeded', $6)
RETURNING id`,
[
`pos_${payment.methodCode}`,
`pos-${order.id}-${index}`,
`pos-${order.id}-${payment.methodCode}-${index}`,
order.id,
payment.amountCents,
JSON.stringify(raw),
],
);
const payRow = payResult.rows[0];
if (!payRow) { await client.query('ROLLBACK'); throw new AppError(500, 'PAYMENT_INSERT_FAILED', 'Payment insert returned no row'); }
paymentResults.push({ id: payRow.id, kind: p.kind, amountCents: p.amountCents });
if (p.kind === 'cash') cashPaymentCents += p.tenderedCents ?? p.amountCents;
}
if (cashPaymentCents > 0) {
const paymentId = inserted.rows[0]?.id;
if (!paymentId)
throw new AppError(500, 'PAYMENT_INSERT_FAILED', 'No se pudo registrar el pago');
await client.query(
`UPDATE pos_cash_sessions SET expected_cash_cents = COALESCE(expected_cash_cents, 0) + $1, updated_at = now() WHERE id = $2`,
[cashPaymentCents, input.cashSessionId],
`INSERT INTO reporting_payment_lines (
order_id, store_id, terminal_id, cash_session_id, payment_method_id,
provider, amount_cents, currency, status, provider_ref
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'EUR', 'payment', $8)`,
[
order.id,
session.store_id,
input.terminalId,
input.cashSessionId,
payment.methodId,
`pos_${payment.methodCode}`,
payment.amountCents,
paymentId,
],
);
if (payment.kind === 'cash') cashAppliedCents += payment.amountCents;
paymentResults.push({ id: paymentId, ...payment });
}
if (cashAppliedCents > 0) {
await client.query(
`UPDATE pos_cash_sessions
SET expected_cash_cents = COALESCE(expected_cash_cents, opening_cash_cents) + $1,
updated_at = now()
WHERE id = $2`,
[cashAppliedCents, input.cashSessionId],
);
}
const receipt = await buildPosReceipt(client, order.id);
await client.query('COMMIT');
return {
orderId,
orderId: order.id,
idempotencyKey: input.idempotencyKey,
receiptNumber,
totalCents,
items: input.items,
changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0),
items,
payments: paymentResults,
createdAt: orderRow.created_at,
receipt,
createdAt: order.created_at,
};
} catch (err) {
} catch (error) {
await client.query('ROLLBACK');
throw err;
throw error;
} finally {
client.release();
}
}
}
function providerToPaymentKind(provider: string): PosPaymentResult['kind'] {
if (provider === 'pos_cash') return 'cash';
if (provider === 'pos_card') return 'card';
return 'other';
private async loadResult(orderId: string, idempotencyKey: string): Promise<PosSaleResult> {
const receipt = await buildPosReceipt(this.pool, orderId);
const paymentRows = await this.pool.query<{
id: string;
amount_cents: number;
raw: Record<string, unknown>;
}>(
`SELECT id, amount_cents, raw FROM payments_transactions
WHERE order_id = $1 AND status = 'succeeded' ORDER BY created_at, id`,
[orderId],
);
const payments: PosPaymentResult[] = paymentRows.rows.map((row, index) => ({
id: row.id,
...(receipt.payments[index] ?? {
methodCode: String(row.raw.methodCode ?? 'other'),
methodLabel: String(row.raw.methodLabel ?? 'Otro'),
kind: 'other' as const,
amountCents: Number(row.amount_cents),
tenderedCents: null,
changeCents: 0,
}),
}));
const items: PosSaleLineItem[] = receipt.items.map((item) => ({
kind: item.freeItem ? 'free' : 'stock',
variantId: null,
productId: null,
sku: item.sku,
ean: null,
name: item.name,
unitPriceCents: item.unitPriceCents,
discountCents: item.quantity > 0 ? Math.round(item.discountCents / item.quantity) : 0,
taxCents: item.quantity > 0 ? Math.round(item.taxCents / item.quantity) : 0,
quantity: item.quantity,
vatRate: null,
}));
return {
orderId,
idempotencyKey,
receiptNumber: receipt.receiptNumber,
totalCents: receipt.totalCents,
changeCents: receipt.changeCents,
items,
payments,
receipt,
createdAt: receipt.issuedAt,
};
}
}

View File

@@ -1,6 +1,30 @@
export interface PosSaleLineItem {
export interface PosStockSaleLineInput {
kind?: 'stock';
variantId: string;
productId: string;
quantity: number;
discountCents?: number;
// Legacy snapshot fields are accepted at the API boundary but ignored.
productId?: string;
sku?: string;
ean?: string | null;
name?: string;
unitPriceCents?: number;
taxCents?: number;
}
export interface PosFreeSaleLineInput {
kind: 'free';
name: string;
unitPriceCents: number;
quantity: number;
}
export type PosSaleLineInput = PosStockSaleLineInput | PosFreeSaleLineInput;
export interface PosSaleLineItem {
kind: 'stock' | 'free';
variantId: string | null;
productId: string | null;
sku: string;
ean: string | null;
name: string;
@@ -8,12 +32,16 @@ export interface PosSaleLineItem {
discountCents: number;
taxCents: number;
quantity: number;
vatRate: string | null;
}
export type PosPaymentKind = 'cash' | 'card' | 'other';
export interface PosPaymentInput {
kind: PosPaymentKind;
/** Configured payment method code. */
methodCode?: string;
/** Legacy field; used only to resolve cash/card defaults. */
kind?: PosPaymentKind;
amountCents: number;
/** For cash payments: actual amount given by customer. */
tenderedCents?: number;
@@ -26,23 +54,71 @@ export interface PosSaleInput {
cashSessionId: string;
terminalId: string;
userId: string;
items: PosSaleLineItem[];
items: PosSaleLineInput[];
payments: PosPaymentInput[];
/** Optional customer ID for loyalty. */
customerId?: string;
}
export interface PosReceiptItem {
name: string;
sku: string;
quantity: number;
unitPriceCents: number;
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
freeItem: boolean;
}
export interface PosReceiptPayment {
methodCode: string;
methodLabel: string;
kind: PosPaymentKind;
amountCents: number;
tenderedCents: number | null;
changeCents: number;
}
export interface PosReceipt {
receiptNumber: string;
orderId: string;
issuedAt: Date;
company: {
name: string;
address: string | null;
taxId: string | null;
email: string | null;
phone: string | null;
};
terminal: { id: string; name: string };
cashier: string;
sessionId: string;
customerEmail: string | null;
items: PosReceiptItem[];
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
payments: PosReceiptPayment[];
changeCents: number;
header: string | null;
returnPolicy: string;
footer: string | null;
}
export interface PosSaleResult {
orderId: string;
idempotencyKey: string;
receiptNumber: string;
totalCents: number;
changeCents: number;
items: PosSaleLineItem[];
payments: PosPaymentResult[];
receipt: PosReceipt;
createdAt: Date;
}
export interface PosPaymentResult {
export interface PosPaymentResult extends PosReceiptPayment {
id: string;
kind: PosPaymentKind;
amountCents: number;
}

View File

@@ -0,0 +1,18 @@
/** Public API of the POS module. */
export { registerPosRoutes, type PosRouteDeps } from './api/pos.routes.js';
export {
CreatePosSaleUseCase,
validatePaymentAllocations,
type ConfiguredPaymentMethod,
type ValidatedPayment,
} from './application/create-pos-sale.js';
export { buildPosReceipt } from './application/build-pos-receipt.js';
export type {
PosSaleInput,
PosSaleResult,
PosSaleLineInput,
PosSaleLineItem,
PosPaymentInput,
PosPaymentResult,
PosReceipt,
} from './domain/pos-sale.js';

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { AppError } from '../../../shared/errors.js';
import {
validatePaymentAllocations,
type ConfiguredPaymentMethod,
} from '../application/create-pos-sale.js';
const methods: ConfiguredPaymentMethod[] = [
{ id: 'cash-id', code: 'cash', label: 'Efectivo', kind: 'cash' },
{ id: 'card-id', code: 'card', label: 'Tarjeta', kind: 'card' },
{ id: 'bizum-id', code: 'bizum', label: 'Bizum', kind: 'other' },
];
describe('POS payment allocation', () => {
it('supports mixed partial payments and calculates cash change', () => {
const result = validatePaymentAllocations(
2_000,
[
{ methodCode: 'cash', amountCents: 500, tenderedCents: 1_000 },
{ methodCode: 'bizum', amountCents: 1_500 },
],
methods,
);
expect(result).toMatchObject([
{ methodCode: 'cash', amountCents: 500, tenderedCents: 1_000, changeCents: 500 },
{ methodCode: 'bizum', amountCents: 1_500, tenderedCents: null, changeCents: 0 },
]);
});
it('requires applied amounts to equal the total exactly', () => {
expectCode(
() =>
validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 1_999 }], methods),
'POS_PAYMENT_TOTAL_MISMATCH',
);
expectCode(
() =>
validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 2_001 }], methods),
'POS_PAYMENT_TOTAL_MISMATCH',
);
});
it('rejects disabled methods and tendered amounts on non-cash methods', () => {
expectCode(
() => validatePaymentAllocations(100, [{ methodCode: 'stripe', amountCents: 100 }], methods),
'POS_PAYMENT_METHOD_DISABLED',
);
expectCode(
() =>
validatePaymentAllocations(
100,
[{ methodCode: 'card', amountCents: 100, tenderedCents: 100 }],
methods,
),
'POS_NON_CASH_TENDERED',
);
});
it('keeps legacy kind mapping for cash and card clients', () => {
expect(
validatePaymentAllocations(100, [{ kind: 'card', amountCents: 100 }], methods)[0],
).toMatchObject({ methodCode: 'card', kind: 'card' });
});
});
function expectCode(action: () => unknown, code: string): void {
try {
action();
throw new Error('Expected action to throw');
} catch (error) {
expect(error).toBeInstanceOf(AppError);
expect((error as AppError).code).toBe(code);
}
}

View File

@@ -0,0 +1,80 @@
# F-186 — Architecture
## Decision
Implement the request as one coherent POS checkout state machine while preserving the existing `POST /pos/sales` entry point. Payment allocation remains client-side draft state; the server receives and atomically validates the final allocation only after explicit user confirmation.
## Persistence
Add migration `054_pos_checkout_receipts.js`:
- `pos_receipt_settings(store_id PK, prefix, next_number, padding, return_policy, updated_at)` provides configurable, transaction-safe numbering and return text.
- `orders_orders.receipt_number` stores the immutable human ticket number with a unique partial index.
- `orders_items.product_id` and `variant_id` become nullable and `orders_items.is_free_item` is added. A CHECK requires both IDs for stocked lines and both NULL for free lines.
- Seed receipt settings for the default store. Existing rows remain stocked lines.
Terminal JSON settings remain appropriate for device-specific switches:
- `touchNavigationEnabled`
- `quickProductVariantIds` (exactly eight slots)
- `lineDiscountsEnabled` (default true)
Existing `pos_payment_methods` remains the source of truth for configurable methods. No provider SDK integration is introduced.
## Sale contract
Stock line request: `{ kind:'stock', variantId, quantity, discountCents }`.
Free line request: `{ kind:'free', name, unitPriceCents, quantity }`.
Legacy stocked line fields remain accepted for compatibility but are ignored for price/name/SKU snapshots.
Payment request: `{ methodCode, amountCents, tenderedCents? }`. Legacy `{ kind }` maps to the seeded code where possible.
Server invariants:
1. Session belongs to terminal and is open.
2. Terminal identity from request header must match body terminal.
3. Stock lines are reloaded from catalog/pricing for the session store and inventory rows are locked.
4. Discounts are blocked if terminal settings disable them; otherwise validated against authoritative gross price.
5. Free items require a trimmed name, positive cent price, positive integer quantity, and never touch inventory.
6. Every method code must be active for the store.
7. Applied payment amounts equal the sale total exactly. Only cash may have `tenderedCents`, which must be >= applied amount. Change is `tendered-applied`.
8. Sale, item snapshots, stock movement, payment transactions, reporting payment lines, cash-session expected amount, receipt sequence and receipt number commit in one transaction.
9. Cash session expected balance increases by the applied cash amount, not tendered cash, so change is not counted as revenue/cash retained.
## Receipt
The sale response includes a structured receipt assembled from immutable order/item/payment rows plus store/terminal/session metadata. It contains:
- company name, address, tax ID, contact email/phone
- receipt number, date/time, order/session/terminal/cashier
- item quantity, unit amount, subtotal, discount, tax and line total
- subtotal, discount, tax and total
- method labels/codes, applied amount, cash tendered and change
- return policy
`GET /pos/sales/:id/receipt` and `/print` return the same shape. `POST /pos/sales/:id/receipt/email` validates an email address and uses the existing SMTP-backed transactional email helper. Browser print remains the hardware-neutral print implementation.
## Frontend state machine
`editing cart -> allocating payments -> ready to confirm -> submitting -> receipt -> delivered/reset`
- Clicking any configured method opens a touch modal defaulting to the remaining total, with “total” and “partial” controls.
- Allocations remain visible and removable in the cashier; paid and remaining totals are displayed.
- Cash modal also captures tendered amount and previews change.
- Confirmation is a separate action and is enabled only when remaining is zero.
- On successful sale the cart is retained while the receipt modal is open.
- Print calls `window.print()` after rendering a print-only receipt. Email calls the backend endpoint. Either successful action resets cashier for the next sale.
- Add a touch-sized `Artículo libre` modal for name and positive euro price.
- Discount UI is rendered only when `lineDiscountsEnabled !== false`.
## Admin
Expand terminal touch configuration to eight quick slots and a line-discount switch. Add store receipt/company fields and receipt numbering controls. Add payment-method creation, activation and ordering for cash/card/other labels (e.g. Bizum, Stripe, Apple Pay).
## Security
- Admin-only configuration routes.
- POS roles only for sale/receipt delivery.
- Header terminal binding checked against body/session.
- No client-supplied catalog price/name trusted.
- Receipt email endpoint accepts only the generated receipt and validated destination; no arbitrary subject/body.
- Payment processor credentials and card PAN are out of scope and never accepted.
## Tests
- Unit tests for monetary allocation/change/free-line normalization and disabled discounts.
- Integration tests where DB is available for migration, receipt sequence, free item, method validation and stock mutation.
- Typecheck/build all three affected packages; run root tests sequentially for real PostgreSQL integration files.

View File

@@ -0,0 +1,6 @@
# F-186 — Documentation
- Added `docs/pos/POS_CHECKOUT.md` with admin setup, cashier state flow, stock/free line contracts, mixed payment/change invariants, ticket print/email, reporting capture and follow-up roadmap.
- Added a precedence notice in `docs/pos/POS_API.md` because its discovery-era single-payment request is superseded by the implemented F-186 contract.
- Documented that custom method labels do not imply real provider integrations.
- Documented that paid sales are `COMPLETED`; pending balances and returns remain F-188/F-189.

View File

@@ -0,0 +1,56 @@
# F-186 — Implementer evidence
## Implemented
### POS cashier
- Eight quick-product slots rendered in a 4×2 touch grid.
- Touch-sized line-discount button; hidden when `terminal.settings.lineDiscountsEnabled=false`.
- Touch payment modal per configured method with “total” / “partial” choice.
- Mixed payment allocations remain visible with paid and outstanding totals and can be removed.
- Cash tender captures overpayment and previews/records change.
- Separate “Confirmar y cerrar ticket” step; disabled until applied amounts equal total.
- Receipt modal supports browser print and SMTP email; cashier is only reset after one delivery action completes.
- Free-item modal adds non-stock service/product lines by name and positive price.
### Admin
- Terminal config expanded from 4 to 8 quick slots.
- Per-terminal discount switch for self-payment terminals.
- Payment-method management supports custom active/inactive cash/card/other methods (Bizum, Stripe, Apple Pay labels, etc.; no external processor integration).
- Company identity, ticket prefix/next number/padding, header/footer and return-policy settings.
### Backend and persistence
- Migration `054_pos_checkout_receipts.js` adds transaction-safe receipt settings/sequence, immutable receipt numbers, and nullable stock IDs for CHECK-constrained free lines.
- POS sale now reloads catalog prices/names/SKU server-side, locks/decrements stock, rejects disabled discounts/methods, validates exact payment allocation and cash tender, and commits as `COMPLETED`.
- Payment transactions preserve configured method, tender and change in immutable raw data.
- Each payment is captured in `reporting_payment_lines`; this addresses new POS sales not reaching reporting.
- Cash session expected balance increases by applied cash, never by tendered cash (change excluded).
- Rich receipt builder contains company, time, ticket number, item details, totals, payment allocations, change and return policy.
- Receipt access is terminal-scoped for cashier/manager and unrestricted only for admin.
- Receipt email uses existing SMTP settings and cannot inject custom content.
- POS cashier creation was corrected to use `backoffice_users` with Argon2 instead of invalid identity rows/SHA-256.
## Tests and verification
- `cd project && npm run typecheck` — PASS.
- `cd project && npm test` — PASS: 268 unit tests; DB tests skipped without env.
- `TEST_DATABASE_URL=... npx vitest run --no-file-parallelism` — PASS: 78 files / 350 tests, including all real-PostgreSQL integration files sequentially.
- New `payment-allocation.test.ts` — PASS: mixed partial allocation, cash change, exact total, disabled method and legacy mapping.
- New `pos-checkout-receipts.itest.ts` — PASS: complete state, stock decrement, free line, mixed payment/change, reporting capture, receipt sequence, cash expected amount and idempotent replay.
- Migration fresh up / no-op second up / full down / re-up — PASS.
- POS Next.js production build — PASS.
- Admin Next.js production build — PASS (pre-existing dynamic upload tracing warnings only).
- `./scripts/verify.sh` — PASS.
- `git diff --check` — PASS.
- Targeted ESLint and Prettier for all changed backend/admin/POS files — PASS.
## Known repository-wide pre-existing checks
- Root `npm run lint` remains red on 9 unrelated pre-existing errors in `generate-thumbnails.mjs`, `log-broadcaster.ts`, old `store-repository.test.ts`, and reporting files. Changed files pass targeted ESLint/Prettier.
- Boundary violations were reduced from 8 to 1 by adding the POS public index and removing old deep imports. The remaining violation is pre-existing `security.routes.ts -> infrastructure/logging/log-broadcaster.ts` and is outside F-186.
## Follow-up tickets captured from user additions
- F-187 cashier deletion/deactivation.
- F-188 pending-payment orders (paid POS orders are already `COMPLETED` here).
- F-189 negative quantity returns and return tickets.
- F-190 full reporting update/refresh audit (new POS payment lines are captured here).
- F-191 terminal and daily close reconciliation.
- F-192 simplified storefront VAT label.
- F-193 storefront expiry/weight and shipping-weight calculation.

View File

@@ -0,0 +1,19 @@
{
"feature_id": "F-186",
"agent": "leader",
"stage": "close",
"verdict": "APPROVED",
"checks": [
{ "item": "reviewer/security/qa gates APPROVED", "ok": true },
{ "item": "350/350 tests with real PostgreSQL sequential", "ok": true },
{ "item": "backend/POS/admin typecheck and production builds", "ok": true },
{ "item": "migration up/no-op/down/up", "ok": true },
{ "item": "verify.sh final exit 0", "ok": true },
{ "item": "documentation and follow-up tickets F-187..F-193", "ok": true }
],
"issues": [],
"notes": [
"Unrelated pre-existing upload files are intentionally excluded from the feature commit.",
"Repository-wide lint debt is documented in implementer evidence; changed-file lint is green."
]
}

View File

@@ -0,0 +1,31 @@
{
"feature_id": "F-186",
"agent": "qa",
"stage": "qa_gate",
"verdict": "APPROVED",
"acceptance": [
{ "id": 1, "criterion": "Admin configures and POS renders eight quick products", "ok": true, "evidence": "Admin and touch API enforce length 8; POS renders 8 slots" },
{ "id": 2, "criterion": "Touch line discount can be disabled per terminal", "ok": true, "evidence": "Touch-sized button plus UI hiding and backend POS_DISCOUNTS_DISABLED guard" },
{ "id": 3, "criterion": "Enabled configurable payment methods only", "ok": true, "evidence": "Admin CRUD/toggle; config filters active; sale validates active store methods" },
{ "id": 4, "criterion": "Full/partial mixed allocations display paid and remaining", "ok": true, "evidence": "PaymentModal and cashier allocation list; payment-allocation unit test" },
{ "id": 5, "criterion": "Cash overpayment records change", "ok": true, "evidence": "Unit and PostgreSQL integration assertions for tendered 1000/applied 500/change 500" },
{ "id": 6, "criterion": "Exact allocation and explicit confirmation before completed sale", "ok": true, "evidence": "Confirm button state plus backend exact-total validation; order integration state COMPLETED" },
{ "id": 7, "criterion": "Complete configurable numbered receipt", "ok": true, "evidence": "Receipt builder/UI/email includes company, time, number, lines, quantities, totals, methods, change, return policy" },
{ "id": 8, "criterion": "Print/email then cashier reset", "ok": true, "evidence": "Receipt modal is blocking and resetCashier is only called by completed print/email action" },
{ "id": 9, "criterion": "Free item without inventory mutation", "ok": true, "evidence": "DB CHECK and integration test verify NULL stock IDs while stocked line alone decrements inventory" },
{ "id": 10, "criterion": "Compatibility and green regression", "ok": true, "evidence": "350/350 tests pass with real PostgreSQL sequentially; backend/POS/admin typecheck and builds pass; verify.sh green" }
],
"regression": {
"unit": "268 passed",
"real_postgresql_sequential": "350 passed across 78 files",
"migration_cycle": "fresh up, second no-op, full down, re-up passed",
"pos_build": "passed",
"admin_build": "passed",
"verify": "passed"
},
"issues": [],
"notes": [
"No physical printer or SMTP server was available for hardware delivery; browser print and SMTP dispatch paths were build/type/security validated.",
"New user requests for pending balances, returns and daily close are separately queued as F-188/F-189/F-191."
]
}

View File

@@ -0,0 +1,21 @@
{
"feature_id": "F-186",
"agent": "reviewer",
"stage": "review_gate",
"verdict": "APPROVED",
"checks": [
{ "item": "Eight-slot quick product and terminal discount contracts agree across admin, API and POS", "ok": true },
{ "item": "Payment allocation requires an enabled method and exact authoritative total", "ok": true },
{ "item": "Cash change and expected cash use tendered/applied values correctly", "ok": true },
{ "item": "Sale, stock, reporting lines, payment rows and receipt sequence are atomic and idempotent", "ok": true },
{ "item": "Free items are DB-constrained and excluded from inventory mutation", "ok": true },
{ "item": "Receipt delivery precedes cashier reset", "ok": true },
{ "item": "Unit, integration, migration, typecheck and production builds pass", "ok": true }
],
"issues": [],
"notes": [
"Browser print APIs cannot distinguish a confirmed print from a cancelled print dialog; reset occurs when the print dialog returns.",
"Pending-payment orders and return tickets are intentionally tracked in F-188/F-189, not mixed into this fully-paid checkout contract.",
"Repository-wide lint debt outside changed files remains; targeted changed-file lint is green."
]
}

View File

@@ -0,0 +1,29 @@
{
"feature_id": "F-186",
"agent": "security",
"stage": "security_gate",
"verdict": "APPROVED",
"checks": [
{ "item": "Admin configuration endpoints require admin role", "ok": true },
{ "item": "Sale and receipt endpoints require authenticated POS roles", "ok": true },
{ "item": "Bound terminal header must match sale terminal/session", "ok": true },
{ "item": "Non-admin receipt reads/email are scoped to the originating terminal", "ok": true },
{ "item": "Catalog price/name/SKU and enabled payment methods are server-authoritative", "ok": true },
{ "item": "All new SQL uses bound parameters and transactional row locks", "ok": true },
{ "item": "Receipt email validates destination and uses fixed generated content", "ok": true },
{ "item": "No PAN, processor credentials or arbitrary provider payload accepted", "ok": true },
{ "item": "POS cashier passwords use Argon2 and backoffice identity storage", "ok": true },
{ "item": "Secret scan of changed diff", "ok": true },
{ "item": "Production dependency audit: backend, POS and admin", "ok": true }
],
"dependency_audit": {
"backend": "0 vulnerabilities",
"pos": "0 vulnerabilities",
"admin": "0 vulnerabilities"
},
"issues": [],
"notes": [
"Review is scoped to F-186 endpoints in the legacy monolithic POS route file; unrelated historical placeholder endpoints require separate hardening.",
"External card/Bizum/Stripe/Apple Pay processing remains out of scope; labels only, so no PCI card data is handled."
]
}

View File

@@ -1,3 +1,32 @@
# F-185Integration suite stability
# F-186POS configurable checkout, mixed payments and receipts
Run real-PostgreSQL integration files sequentially because each resets shared schema. Add forward migration correcting reporting_payment_lines currency default to literal EUR.
Complete the TPV cashier flow for touch terminals and self-payment use cases.
## Scope
- Increase configurable quick products from 6 to 8.
- Replace line discount text/inline interaction with a touch-sized button; allow admins to disable line discounts per terminal.
- Let admins configure and enable payment methods (cash, card, Bizum, Stripe, Apple Pay, or another named method).
- Payment modal must allocate either the full remaining amount or a partial amount. Keep partial allocations visible in the cashier and permit another method until the total is covered.
- For cash, accept tendered amount above the outstanding amount and calculate change.
- Require an explicit final confirmation after payment allocation before closing the sale.
- Generate a receipt with company identity, date/time, configurable ticket numbering, item name, quantity, subtotal, totals, payment methods/amounts, cash change, and return policy.
- Offer print and email delivery. Clear the cashier only after print/email action succeeds or is explicitly completed.
- Add a free-item flow for a non-stock product/service with required name and positive price; free items must not mutate inventory.
- Keep monetary validation and sale completion authoritative on the backend.
## Out of scope
- Real integrations with external payment processors.
- Certified fiscal-printer protocols or country-specific fiscal certification.
- Hardware-specific printer drivers; browser print is sufficient.
## Acceptance
1. Admin can configure up to eight quick products and POS renders all configured slots.
2. Line discount is a touch target and is absent/blocked when the terminal disables discounts.
3. Enabled admin payment methods appear in POS; disabled methods cannot be submitted.
4. Cashier supports full and partial payment allocations, displays paid/remaining totals, and permits mixed methods.
5. Cash tender above the remaining total displays and records change; non-cash overpayment is rejected.
6. A sale can only be confirmed when allocations cover the exact total, and requires explicit confirmation.
7. Receipt contains all requested company, numbering, line, total, payment, change, and return-policy data.
8. Receipt supports browser print and email delivery; cashier resets only after delivery completion.
9. A free item can be added with name and positive price and does not reserve or decrement stock.
10. Existing POS sale/reporting contracts remain compatible and tests plus `verify.sh` are green.

View File

@@ -488,3 +488,10 @@
- PG16 no soporta `ADD CONSTRAINT IF NOT EXISTS` → usar `DO $$ IF NOT EXISTS`
- Batch de rutas múltiples en un archivo requiere cuidado con el balanceo de llaves/paréntesis
- El script de análisis de Python para balanceo de llaves cuenta `}` dentro de literales de template como cierres de bloque (da falsos positivos)
## F-186 cerrada (2026-08-22) — POS configurable checkout, mixed payments and receipts
- Gates: reviewer APPROVED, security APPROVED, qa APPROVED, verify.sh exit 0.
- Entregable: 8 productos rápidos, descuentos táctiles/desactivables, métodos configurables, pagos parciales/mixtos, cambio en efectivo, confirmación explícita, artículos libres y tickets numerados por impresión/email.
- Integridad: venta `COMPLETED`, stock/pagos/reporting/caja/secuencia atómicos e idempotentes; precio de catálogo y métodos validados en backend.
- Evidencia: 350/350 tests con PostgreSQL real en secuencia, builds backend/POS/admin verdes; `work/artifacts/F-186/`.
- Seguimiento solicitado: F-187..F-193.

View File

@@ -6,6 +6,6 @@
"state": "waiting",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-22T17:30:45Z",
"updated_at": "2026-08-22T20:08:30Z",
"timeline": []
}