feat(F-187): completed feature
This commit is contained in:
@@ -7223,13 +7223,15 @@
|
|||||||
"description": "Allow safe cashier removal from admin while preserving historical sale and session attribution.",
|
"description": "Allow safe cashier removal from admin while preserving historical sale and session attribution.",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"risk": "med",
|
"risk": "med",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-22",
|
"created_at": "2026-08-22",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
}
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-22T20:23:44Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "F-188",
|
"id": "F-188",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# TPV — Checkout, pagos mixtos y tickets
|
# 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.
|
> Implementado en F-186 y ampliado en F-187. Complementa `POS_API.md` y sustituye el flujo inmediato de pago único descrito en documentos de discovery antiguos.
|
||||||
|
|
||||||
## Configuración en administración
|
## Configuración en administración
|
||||||
|
|
||||||
@@ -35,6 +35,29 @@ Configura:
|
|||||||
|
|
||||||
La asignación del siguiente número se bloquea dentro de la transacción de venta, evitando números duplicados entre terminales concurrentes.
|
La asignación del siguiente número se bloquea dentro de la transacción de venta, evitando números duplicados entre terminales concurrentes.
|
||||||
|
|
||||||
|
### Cajeros
|
||||||
|
|
||||||
|
En **Administración → TPV → Cajeros** puedes crear cuentas con rol cajero y consultar su estado:
|
||||||
|
|
||||||
|
- **Activo**: puede iniciar sesión y abrir caja.
|
||||||
|
- **Inactivo**: no puede autenticarse; se puede reactivar.
|
||||||
|
- **Eliminado**: no puede autenticarse ni reactivarse.
|
||||||
|
|
||||||
|
**Desactivar** revoca inmediatamente todas las sesiones del cajero. Al reactivarlo tendrá que iniciar una sesión nueva.
|
||||||
|
|
||||||
|
**Eliminar** requiere confirmación y realiza una baja lógica irreversible. La fila y el UUID se conservan para que tickets, ventas, sesiones de caja, reporting y auditoría sigan indicando quién realizó la operación.
|
||||||
|
|
||||||
|
No se puede desactivar ni eliminar un cajero con una sesión de caja abierta. Primero debe cerrarse y cuadrarse esa sesión.
|
||||||
|
|
||||||
|
API administrativa:
|
||||||
|
|
||||||
|
- `GET /pos/users`: lista personal POS y estado de lifecycle.
|
||||||
|
- `POST /pos/users`: crea cajero/manager; contraseña mínima de ocho caracteres.
|
||||||
|
- `PATCH /pos/users/:id/status` con `{ "active": false }` o `{ "active": true }`.
|
||||||
|
- `DELETE /pos/users/:id`: baja lógica irreversible del cajero.
|
||||||
|
|
||||||
|
Las operaciones de estado y eliminación solo admiten objetivos con rol `pos_cashier`, requieren administrador y generan eventos de auditoría.
|
||||||
|
|
||||||
## Flujo de caja
|
## Flujo de caja
|
||||||
|
|
||||||
1. Añade productos de catálogo o un **Artículo libre** (nombre y precio positivo).
|
1. Añade productos de catálogo o un **Artículo libre** (nombre y precio positivo).
|
||||||
@@ -137,7 +160,6 @@ El efectivo esperado aumenta por el importe aplicado, no por el efectivo entrega
|
|||||||
|
|
||||||
## Próximas ampliaciones
|
## Próximas ampliaciones
|
||||||
|
|
||||||
- F-187: eliminar/desactivar cajeros preservando histórico.
|
|
||||||
- F-188: ventas con saldo pendiente.
|
- F-188: ventas con saldo pendiente.
|
||||||
- F-189: cantidades negativas, devoluciones parciales/totales y ticket de devolución.
|
- F-189: cantidades negativas, devoluciones parciales/totales y ticket de devolución.
|
||||||
- F-190: auditoría completa de actualización/refresco de reporting.
|
- F-190: auditoría completa de actualización/refresco de reporting.
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ interface PaymentMethod {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
interface PosCashier {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: 'pos_cashier';
|
||||||
|
active: boolean;
|
||||||
|
deactivatedAt: string | null;
|
||||||
|
deletedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
status: 'active' | 'inactive' | 'deleted';
|
||||||
|
}
|
||||||
interface ReceiptSettings {
|
interface ReceiptSettings {
|
||||||
storeId: string;
|
storeId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -85,6 +95,10 @@ export default function PosAdminPage() {
|
|||||||
const [savingTouch, setSavingTouch] = useState(false);
|
const [savingTouch, setSavingTouch] = useState(false);
|
||||||
const [touchMessage, setTouchMessage] = useState('');
|
const [touchMessage, setTouchMessage] = useState('');
|
||||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||||
|
const [cashiers, setCashiers] = useState<PosCashier[]>([]);
|
||||||
|
const [newCashier, setNewCashier] = useState({ email: '', password: '' });
|
||||||
|
const [savingCashier, setSavingCashier] = useState(false);
|
||||||
|
const [cashierMessage, setCashierMessage] = useState('');
|
||||||
const [newMethod, setNewMethod] = useState({
|
const [newMethod, setNewMethod] = useState({
|
||||||
code: '',
|
code: '',
|
||||||
label: '',
|
label: '',
|
||||||
@@ -118,14 +132,16 @@ export default function PosAdminPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [storeData, terminalData, catalogData] = await Promise.all([
|
const [storeData, terminalData, catalogData, userData] = await Promise.all([
|
||||||
api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'),
|
api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'),
|
||||||
api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'),
|
api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'),
|
||||||
api.get<{ items: CatalogOption[] }>('/api/pos/admin/catalog-products'),
|
api.get<{ items: CatalogOption[] }>('/api/pos/admin/catalog-products'),
|
||||||
|
api.get<{ items: Array<PosCashier | { role: string }> }>('/api/pos/users'),
|
||||||
]);
|
]);
|
||||||
setStores(storeData.stores);
|
setStores(storeData.stores);
|
||||||
setTerminals(terminalData.terminals);
|
setTerminals(terminalData.terminals);
|
||||||
setCatalogOptions(catalogData.items);
|
setCatalogOptions(catalogData.items);
|
||||||
|
setCashiers(userData.items.filter((item): item is PosCashier => item.role === 'pos_cashier'));
|
||||||
const selected = storeId || storeData.stores.find((store) => store.active)?.id || '';
|
const selected = storeId || storeData.stores.find((store) => store.active)?.id || '';
|
||||||
setStoreId(selected);
|
setStoreId(selected);
|
||||||
await loadStoreConfiguration(selected);
|
await loadStoreConfiguration(selected);
|
||||||
@@ -229,6 +245,57 @@ export default function PosAdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createCashier = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSavingCashier(true);
|
||||||
|
setCashierMessage('');
|
||||||
|
try {
|
||||||
|
await api.post('/api/pos/users', {
|
||||||
|
email: newCashier.email.trim().toLowerCase(),
|
||||||
|
password: newCashier.password,
|
||||||
|
role: 'pos_cashier',
|
||||||
|
});
|
||||||
|
setNewCashier({ email: '', password: '' });
|
||||||
|
setCashierMessage('Cajero creado');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setCashierMessage(err instanceof Error ? err.message : 'No se pudo crear el cajero');
|
||||||
|
} finally {
|
||||||
|
setSavingCashier(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCashier = async (cashier: PosCashier) => {
|
||||||
|
const nextActive = !cashier.active;
|
||||||
|
const action = nextActive ? 'reactivar' : 'desactivar';
|
||||||
|
if (!window.confirm(`¿Quieres ${action} a ${cashier.email}?`)) return;
|
||||||
|
setCashierMessage('');
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/pos/users/${cashier.id}/status`, { active: nextActive });
|
||||||
|
setCashierMessage(nextActive ? 'Cajero reactivado' : 'Cajero desactivado');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setCashierMessage(err instanceof Error ? err.message : `No se pudo ${action} el cajero`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCashier = async (cashier: PosCashier) => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`¿Eliminar definitivamente a ${cashier.email}? No podrá reactivarse. Su historial de ventas y caja se conservará.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setCashierMessage('');
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/pos/users/${cashier.id}`);
|
||||||
|
setCashierMessage('Cajero eliminado; su histórico se conserva');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setCashierMessage(err instanceof Error ? err.message : 'No se pudo eliminar el cajero');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveReceipt = async (event: React.FormEvent) => {
|
const saveReceipt = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSavingReceipt(true);
|
setSavingReceipt(true);
|
||||||
@@ -362,6 +429,116 @@ export default function PosAdminPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-bold">Cajeros</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Desactivar corta el acceso y permite reactivarlo. Eliminar es irreversible, pero
|
||||||
|
conserva la atribución histórica de ventas y sesiones de caja.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={createCashier}
|
||||||
|
className="mt-5 grid gap-3 rounded-xl bg-gray-50 p-4 md:grid-cols-[1fr_1fr_auto] md:items-end"
|
||||||
|
>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={newCashier.email}
|
||||||
|
onChange={(event) => setNewCashier({ ...newCashier, email: event.target.value })}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full rounded-xl border px-3 py-2.5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Contraseña inicial
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newCashier.password}
|
||||||
|
onChange={(event) => setNewCashier({ ...newCashier, password: event.target.value })}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
maxLength={200}
|
||||||
|
className="mt-1 w-full rounded-xl border px-3 py-2.5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
disabled={savingCashier}
|
||||||
|
className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingCashier ? 'Creando…' : 'Crear cajero'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-5 overflow-x-auto rounded-xl border">
|
||||||
|
<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">Email</th>
|
||||||
|
<th className="px-4 py-3">Estado</th>
|
||||||
|
<th className="px-4 py-3">Creado</th>
|
||||||
|
<th className="px-4 py-3 text-right">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{cashiers.map((cashier) => (
|
||||||
|
<tr key={cashier.id} className={cashier.status === 'deleted' ? 'opacity-60' : ''}>
|
||||||
|
<td className="px-4 py-3 font-medium">{cashier.email}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2.5 py-1 text-xs font-semibold ${
|
||||||
|
cashier.status === 'active'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: cashier.status === 'inactive'
|
||||||
|
? 'bg-amber-100 text-amber-800'
|
||||||
|
: 'bg-gray-200 text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cashier.status === 'active'
|
||||||
|
? 'Activo'
|
||||||
|
: cashier.status === 'inactive'
|
||||||
|
? 'Inactivo'
|
||||||
|
: 'Eliminado'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">
|
||||||
|
{new Date(cashier.createdAt).toLocaleDateString('es-ES')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{cashier.status !== 'deleted' && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleCashier(cashier)}
|
||||||
|
className="rounded-lg border px-3 py-2 text-xs font-semibold"
|
||||||
|
>
|
||||||
|
{cashier.active ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void deleteCashier(cashier)}
|
||||||
|
className="rounded-lg border border-red-200 px-3 py-2 text-xs font-semibold text-red-700"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!loading && cashiers.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
No hay cajeros configurados.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{cashierMessage && <p className="mt-3 text-sm">{cashierMessage}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
{configuring && (
|
{configuring && (
|
||||||
<section className="rounded-xl border border-gray-200 bg-white p-6">
|
<section className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|||||||
41
project/migrations/055_pos_cashier_lifecycle.js
Normal file
41
project/migrations/055_pos_cashier_lifecycle.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* F-187 — Safe POS cashier lifecycle.
|
||||||
|
*
|
||||||
|
* POS cashiers are referenced by cash sessions and receipt/reporting history.
|
||||||
|
* Their rows therefore use deactivation/deletion tombstones instead of physical
|
||||||
|
* deletion. Existing backoffice accounts remain active.
|
||||||
|
*
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.addColumns('backoffice_users', {
|
||||||
|
active: { type: 'boolean', notNull: true, default: true },
|
||||||
|
deactivated_at: { type: 'timestamptz' },
|
||||||
|
deleted_at: { type: 'timestamptz' },
|
||||||
|
});
|
||||||
|
|
||||||
|
pgm.addConstraint('backoffice_users', 'backoffice_users_lifecycle_check', {
|
||||||
|
check:
|
||||||
|
'(active = true AND deactivated_at IS NULL AND deleted_at IS NULL) OR ' +
|
||||||
|
'(active = false AND deactivated_at IS NOT NULL)',
|
||||||
|
});
|
||||||
|
|
||||||
|
pgm.createIndex('backoffice_users', ['role', 'active', 'deleted_at'], {
|
||||||
|
name: 'backoffice_users_pos_cashier_lifecycle_idx',
|
||||||
|
where: "role = 'pos_cashier'",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.dropIndex('backoffice_users', ['role', 'active', 'deleted_at'], {
|
||||||
|
name: 'backoffice_users_pos_cashier_lifecycle_idx',
|
||||||
|
ifExists: true,
|
||||||
|
});
|
||||||
|
pgm.dropConstraint('backoffice_users', 'backoffice_users_lifecycle_check', {
|
||||||
|
ifExists: true,
|
||||||
|
});
|
||||||
|
pgm.dropColumns('backoffice_users', ['active', 'deactivated_at', 'deleted_at'], {
|
||||||
|
ifExists: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
237
project/src/app/tests/pos-cashier-lifecycle.itest.ts
Normal file
237
project/src/app/tests/pos-cashier-lifecycle.itest.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import type pg from 'pg';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { buildApp } from '../build-app.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 ADMIN_ID = '10000000-0000-4000-8000-000000000187';
|
||||||
|
const CASHIER_ID = '20000000-0000-4000-8000-000000000187';
|
||||||
|
const MANAGER_ID = '30000000-0000-4000-8000-000000000187';
|
||||||
|
const TERMINAL_ID = '40000000-0000-4000-8000-000000000187';
|
||||||
|
const STORE_ID = '00000000-0000-0000-0000-000000000001';
|
||||||
|
const CASH_SESSION_ID = '50000000-0000-4000-8000-000000000187';
|
||||||
|
const ADMIN_TOKEN = 'f187-admin-session-token';
|
||||||
|
const CASHIER_TOKEN = 'f187-cashier-session-token';
|
||||||
|
const PASSWORD = 'cashier-password-187';
|
||||||
|
|
||||||
|
function tokenHash(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookie(token: string): string {
|
||||||
|
return `backoffice_session=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!hasDb)('F-187 POS cashier lifecycle (real PostgreSQL)', () => {
|
||||||
|
const url = hasDb ? getTestDbUrl() : '';
|
||||||
|
let pool: pg.Pool;
|
||||||
|
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await recreateDatabase(url);
|
||||||
|
await runMigrations(url, 'up');
|
||||||
|
pool = createPool(url);
|
||||||
|
const passwordHash = await argon2.hash(PASSWORD);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO backoffice_users (id, email, password_hash, role)
|
||||||
|
VALUES
|
||||||
|
($1, 'admin-f187@example.test', $4, 'admin'),
|
||||||
|
($2, 'cashier-f187@example.test', $4, 'pos_cashier'),
|
||||||
|
($3, 'manager-f187@example.test', $4, 'pos_manager')`,
|
||||||
|
[ADMIN_ID, CASHIER_ID, MANAGER_ID, passwordHash],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO backoffice_sessions (user_id, token_hash, expires_at)
|
||||||
|
VALUES ($1, $3, now() + interval '1 hour'),
|
||||||
|
($2, $4, now() + interval '1 hour')`,
|
||||||
|
[ADMIN_ID, CASHIER_ID, tokenHash(ADMIN_TOKEN), tokenHash(CASHIER_TOKEN)],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO pos_terminals (id, store_id, name, binding_code, bound_at)
|
||||||
|
VALUES ($1, $2, 'Caja F-187', 'F187CODE', now())`,
|
||||||
|
[TERMINAL_ID, STORE_ID],
|
||||||
|
);
|
||||||
|
app = await buildApp({ pool, cookieSecure: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists lifecycle status for admins and rejects non-admin listing', async () => {
|
||||||
|
const listed = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/pos/users',
|
||||||
|
headers: { cookie: cookie(ADMIN_TOKEN) },
|
||||||
|
});
|
||||||
|
expect(listed.statusCode).toBe(200);
|
||||||
|
expect(listed.json().items).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: CASHIER_ID,
|
||||||
|
role: 'pos_cashier',
|
||||||
|
active: true,
|
||||||
|
status: 'active',
|
||||||
|
deletedAt: null,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const forbidden = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/pos/users',
|
||||||
|
headers: { cookie: cookie(CASHIER_TOKEN) },
|
||||||
|
});
|
||||||
|
expect(forbidden.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivates, revokes sessions, blocks auth and can reactivate without restoring sessions', async () => {
|
||||||
|
const deactivated = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/pos/users/${CASHIER_ID}/status`,
|
||||||
|
headers: { cookie: cookie(ADMIN_TOKEN) },
|
||||||
|
payload: { active: false },
|
||||||
|
});
|
||||||
|
expect(deactivated.statusCode).toBe(200);
|
||||||
|
expect(deactivated.json()).toMatchObject({ id: CASHIER_ID, active: false, status: 'inactive' });
|
||||||
|
|
||||||
|
const session = await pool.query<{ revoked_at: Date | null }>(
|
||||||
|
'SELECT revoked_at FROM backoffice_sessions WHERE user_id = $1',
|
||||||
|
[CASHIER_ID],
|
||||||
|
);
|
||||||
|
expect(session.rows[0]?.revoked_at).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
const oldSession = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/pos/config',
|
||||||
|
headers: { cookie: cookie(CASHIER_TOKEN), 'x-terminal-id': TERMINAL_ID },
|
||||||
|
});
|
||||||
|
expect(oldSession.statusCode).toBe(401);
|
||||||
|
|
||||||
|
const blockedLogin = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/backoffice/auth/login',
|
||||||
|
payload: { email: 'cashier-f187@example.test', password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(blockedLogin.statusCode).toBe(401);
|
||||||
|
expect(blockedLogin.json().error.code).toBe('INVALID_CREDENTIALS');
|
||||||
|
|
||||||
|
const reactivated = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/pos/users/${CASHIER_ID}/status`,
|
||||||
|
headers: { cookie: cookie(ADMIN_TOKEN) },
|
||||||
|
payload: { active: true },
|
||||||
|
});
|
||||||
|
expect(reactivated.statusCode).toBe(200);
|
||||||
|
expect(reactivated.json()).toMatchObject({ active: true, status: 'active' });
|
||||||
|
|
||||||
|
const revokedStill = await pool.query<{ revoked_at: Date | null }>(
|
||||||
|
'SELECT revoked_at FROM backoffice_sessions WHERE user_id = $1',
|
||||||
|
[CASHIER_ID],
|
||||||
|
);
|
||||||
|
expect(revokedStill.rows[0]?.revoked_at).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
const login = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/backoffice/auth/login',
|
||||||
|
payload: { email: 'cashier-f187@example.test', password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(login.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks lifecycle mutations during an open cash session', async () => {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO pos_cash_sessions
|
||||||
|
(id, terminal_id, store_id, user_id, opening_cash_cents)
|
||||||
|
VALUES ($1, $2, $3, $4, 1000)`,
|
||||||
|
[CASH_SESSION_ID, TERMINAL_ID, STORE_ID, CASHIER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const request of [
|
||||||
|
{ method: 'PATCH' as const, payload: { active: false } },
|
||||||
|
{ method: 'DELETE' as const, payload: undefined },
|
||||||
|
]) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: request.method,
|
||||||
|
url:
|
||||||
|
request.method === 'PATCH'
|
||||||
|
? `/pos/users/${CASHIER_ID}/status`
|
||||||
|
: `/pos/users/${CASHIER_ID}`,
|
||||||
|
headers: { cookie: cookie(ADMIN_TOKEN) },
|
||||||
|
...(request.payload ? { payload: request.payload } : {}),
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json().error.code).toBe('POS_CASHIER_HAS_OPEN_SESSION');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft-deletes after close, preserves attribution and rejects reactivation', async () => {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE pos_cash_sessions SET status = 'CLOSED', closed_at = now(), updated_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[CASH_SESSION_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleted = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/pos/users/${CASHIER_ID}`,
|
||||||
|
headers: { cookie: cookie(ADMIN_TOKEN) },
|
||||||
|
});
|
||||||
|
expect(deleted.statusCode).toBe(204);
|
||||||
|
|
||||||
|
const preserved = await pool.query<{
|
||||||
|
id: string;
|
||||||
|
active: boolean;
|
||||||
|
deleted_at: Date | null;
|
||||||
|
historical_user_id: string;
|
||||||
|
}>(
|
||||||
|
`SELECT u.id, u.active, u.deleted_at, cs.user_id AS historical_user_id
|
||||||
|
FROM backoffice_users u
|
||||||
|
JOIN pos_cash_sessions cs ON cs.user_id = u.id
|
||||||
|
WHERE u.id = $1 AND cs.id = $2`,
|
||||||
|
[CASHIER_ID, CASH_SESSION_ID],
|
||||||
|
);
|
||||||
|
expect(preserved.rows[0]).toMatchObject({
|
||||||
|
id: CASHIER_ID,
|
||||||
|
active: false,
|
||||||
|
historical_user_id: CASHIER_ID,
|
||||||
|
});
|
||||||
|
expect(preserved.rows[0]?.deleted_at).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
const reactivate = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/pos/users/${CASHIER_ID}/status`,
|
||||||
|
headers: { cookie: cookie(ADMIN_TOKEN) },
|
||||||
|
payload: { active: true },
|
||||||
|
});
|
||||||
|
expect(reactivate.statusCode).toBe(409);
|
||||||
|
expect(reactivate.json().error.code).toBe('POS_CASHIER_DELETED');
|
||||||
|
|
||||||
|
const managerTarget = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/pos/users/${MANAGER_ID}/status`,
|
||||||
|
headers: { cookie: cookie(ADMIN_TOKEN) },
|
||||||
|
payload: { active: false },
|
||||||
|
});
|
||||||
|
expect(managerTarget.statusCode).toBe(404);
|
||||||
|
expect(managerTarget.json().error.code).toBe('POS_CASHIER_NOT_FOUND');
|
||||||
|
|
||||||
|
const audit = await pool.query<{ action: string }>(
|
||||||
|
`SELECT action FROM security_audit_log WHERE target = $1 ORDER BY created_at`,
|
||||||
|
[CASHIER_ID],
|
||||||
|
);
|
||||||
|
expect(audit.rows.map((row) => row.action)).toEqual([
|
||||||
|
'pos.cashier.deactivated',
|
||||||
|
'pos.cashier.reactivated',
|
||||||
|
'pos.cashier.deleted',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,7 +71,7 @@ export async function registerBackofficeRoutes(
|
|||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', format: 'uuid' },
|
id: { type: 'string', format: 'uuid' },
|
||||||
email: { type: 'string', format: 'email' },
|
email: { type: 'string', format: 'email' },
|
||||||
role: { type: 'string', enum: ['admin', 'editor'] },
|
role: { type: 'string', enum: ['admin', 'editor', 'pos_manager', 'pos_cashier'] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
401: errorSchema,
|
401: errorSchema,
|
||||||
@@ -97,7 +97,7 @@ export async function registerBackofficeRoutes(
|
|||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', format: 'uuid' },
|
id: { type: 'string', format: 'uuid' },
|
||||||
email: { type: 'string', format: 'email' },
|
email: { type: 'string', format: 'email' },
|
||||||
role: { type: 'string', enum: ['admin', 'editor'] },
|
role: { type: 'string', enum: ['admin', 'editor', 'pos_manager', 'pos_cashier'] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'object', properties: { user: { type: 'null' } } },
|
{ type: 'object', properties: { user: { type: 'null' } } },
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
* Backoffice users (admin/editor) are physically separated from storefront
|
* Backoffice users (admin/editor) are physically separated from storefront
|
||||||
* customers (identity_users) and authenticate through a separate mechanism.
|
* customers (identity_users) and authenticate through a separate mechanism.
|
||||||
*/
|
*/
|
||||||
export type BackofficeRole = 'admin' | 'editor';
|
export type BackofficeRole = 'admin' | 'editor' | 'pos_cashier' | 'pos_manager';
|
||||||
|
|
||||||
export interface BackofficeUser {
|
export interface BackofficeUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: BackofficeRole;
|
role: BackofficeRole;
|
||||||
mfaEnrolled: boolean;
|
mfaEnrolled: boolean;
|
||||||
|
active: boolean;
|
||||||
|
deactivatedAt: Date | null;
|
||||||
|
deletedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyRequest } from 'fastify';
|
||||||
import type pg from 'pg';
|
import type pg from 'pg';
|
||||||
import type { Authenticate } from '../../../shared/auth.js';
|
import type { Authenticate, Role } from '../../../shared/auth.js';
|
||||||
import { AppError } from '../../../shared/errors.js';
|
import { AppError } from '../../../shared/errors.js';
|
||||||
import { hashBackofficeToken } from './backoffice-session-token.js';
|
import { hashBackofficeToken } from './backoffice-session-token.js';
|
||||||
import { BACKOFFICE_SESSION_COOKIE_NAME } from '../api/backoffice.routes.js';
|
import { BACKOFFICE_SESSION_COOKIE_NAME } from '../api/backoffice.routes.js';
|
||||||
@@ -22,6 +22,8 @@ const RESOLVE_SQL = `
|
|||||||
WHERE s.token_hash = $1
|
WHERE s.token_hash = $1
|
||||||
AND s.revoked_at IS NULL
|
AND s.revoked_at IS NULL
|
||||||
AND s.expires_at > now()
|
AND s.expires_at > now()
|
||||||
|
AND u.active = true
|
||||||
|
AND u.deleted_at IS NULL
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function createBackofficeSessionAuthenticator(pool: pg.Pool): Authenticate {
|
export function createBackofficeSessionAuthenticator(pool: pg.Pool): Authenticate {
|
||||||
@@ -31,7 +33,7 @@ export function createBackofficeSessionAuthenticator(pool: pg.Pool): Authenticat
|
|||||||
const result = await pool.query<ResolvedRow>(RESOLVE_SQL, [hashBackofficeToken(token)]);
|
const result = await pool.query<ResolvedRow>(RESOLVE_SQL, [hashBackofficeToken(token)]);
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
if (!row) throw new AppError(401, 'UNAUTHORIZED', 'Backoffice authentication required');
|
if (!row) throw new AppError(401, 'UNAUTHORIZED', 'Backoffice authentication required');
|
||||||
return { id: row.id, email: row.email, role: row.role as 'admin' | 'editor' };
|
return { id: row.id, email: row.email, role: row.role as Role };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ interface UserRow {
|
|||||||
password_hash: string;
|
password_hash: string;
|
||||||
role: string;
|
role: string;
|
||||||
mfa_enrolled: boolean;
|
mfa_enrolled: boolean;
|
||||||
|
active: boolean;
|
||||||
|
deactivated_at: Date | null;
|
||||||
|
deleted_at: Date | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@@ -47,7 +50,8 @@ export class PgBackofficeUserRepository implements BackofficeUserRepository {
|
|||||||
|
|
||||||
async findByEmail(email: string): Promise<BackofficeUserWithHash | undefined> {
|
async findByEmail(email: string): Promise<BackofficeUserWithHash | undefined> {
|
||||||
const result = await this.pool.query<UserRow>(
|
const result = await this.pool.query<UserRow>(
|
||||||
'SELECT * FROM backoffice_users WHERE email = $1',
|
`SELECT * FROM backoffice_users
|
||||||
|
WHERE email = $1 AND active = true AND deleted_at IS NULL`,
|
||||||
[email.toLowerCase()],
|
[email.toLowerCase()],
|
||||||
);
|
);
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
@@ -76,6 +80,9 @@ function toUser(row: UserRow): BackofficeUser {
|
|||||||
email: row.email,
|
email: row.email,
|
||||||
role: row.role as BackofficeRole,
|
role: row.role as BackofficeRole,
|
||||||
mfaEnrolled: row.mfa_enrolled,
|
mfaEnrolled: row.mfa_enrolled,
|
||||||
|
active: row.active,
|
||||||
|
deactivatedAt: row.deactivated_at,
|
||||||
|
deletedAt: row.deleted_at,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2190,8 +2190,17 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
const user = await authenticate(request);
|
const user = await authenticate(request);
|
||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT id, email, role FROM backoffice_users
|
`SELECT id, email, role, active,
|
||||||
WHERE role IN ('pos_manager','pos_cashier') ORDER BY email`,
|
deactivated_at AS "deactivatedAt", deleted_at AS "deletedAt",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
CASE
|
||||||
|
WHEN deleted_at IS NOT NULL THEN 'deleted'
|
||||||
|
WHEN active THEN 'active'
|
||||||
|
ELSE 'inactive'
|
||||||
|
END AS status
|
||||||
|
FROM backoffice_users
|
||||||
|
WHERE role IN ('pos_manager','pos_cashier')
|
||||||
|
ORDER BY deleted_at NULLS FIRST, active DESC, email`,
|
||||||
);
|
);
|
||||||
return reply.send({ items: result.rows });
|
return reply.send({ items: result.rows });
|
||||||
},
|
},
|
||||||
@@ -2246,7 +2255,212 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
);
|
);
|
||||||
const created = newUser.rows[0];
|
const created = newUser.rows[0];
|
||||||
if (!created) throw new AppError(500, 'USER_CREATE_FAILED', 'User insert returned no row');
|
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 });
|
return reply.code(201).send({
|
||||||
|
id: created.id,
|
||||||
|
email: body.email,
|
||||||
|
role: body.role,
|
||||||
|
active: true,
|
||||||
|
deactivatedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.patch(
|
||||||
|
'/pos/users/:id/status',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['POS Admin'],
|
||||||
|
summary: 'Activate or deactivate a POS cashier',
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: { id: { type: 'string', format: 'uuid' } },
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['active'],
|
||||||
|
properties: { active: { type: 'boolean' } },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
400: errorSchema,
|
||||||
|
401: errorSchema,
|
||||||
|
403: errorSchema,
|
||||||
|
404: errorSchema,
|
||||||
|
409: errorSchema,
|
||||||
|
},
|
||||||
|
} as FastifySchema,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const admin = await authenticate(request);
|
||||||
|
requireRole(admin, 'admin');
|
||||||
|
const { id } = parseJson(idParamSchema, request.params);
|
||||||
|
const { active } = parseJson(z.object({ active: z.boolean() }), request.body ?? {});
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const targetResult = await client.query<{
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
active: boolean;
|
||||||
|
deleted_at: Date | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id, email, active, deleted_at FROM backoffice_users
|
||||||
|
WHERE id = $1 AND role = 'pos_cashier' FOR UPDATE`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
const target = targetResult.rows[0];
|
||||||
|
if (!target) {
|
||||||
|
throw new AppError(404, 'POS_CASHIER_NOT_FOUND', 'Cajero no encontrado');
|
||||||
|
}
|
||||||
|
if (target.deleted_at) {
|
||||||
|
throw new AppError(
|
||||||
|
409,
|
||||||
|
'POS_CASHIER_DELETED',
|
||||||
|
'Un cajero eliminado no se puede reactivar',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!active && target.active) {
|
||||||
|
const open = await client.query(
|
||||||
|
`SELECT id FROM pos_cash_sessions
|
||||||
|
WHERE user_id = $1 AND status = 'OPEN' LIMIT 1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (open.rows[0]) {
|
||||||
|
throw new AppError(
|
||||||
|
409,
|
||||||
|
'POS_CASHIER_HAS_OPEN_SESSION',
|
||||||
|
'Cierra la sesión de caja antes de desactivar el cajero',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updated = await client.query<{
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
active: boolean;
|
||||||
|
deactivatedAt: Date | null;
|
||||||
|
deletedAt: Date | null;
|
||||||
|
}>(
|
||||||
|
`UPDATE backoffice_users
|
||||||
|
SET active = $2,
|
||||||
|
deactivated_at = CASE WHEN $2 THEN NULL ELSE COALESCE(deactivated_at, now()) END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, email, active,
|
||||||
|
deactivated_at AS "deactivatedAt", deleted_at AS "deletedAt"`,
|
||||||
|
[id, active],
|
||||||
|
);
|
||||||
|
if (!active) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE backoffice_sessions SET revoked_at = COALESCE(revoked_at, now())
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO security_audit_log (actor_id, action, target, metadata)
|
||||||
|
VALUES ($1, $2, $3, jsonb_build_object('email', $4::text))`,
|
||||||
|
[
|
||||||
|
admin.id,
|
||||||
|
active ? 'pos.cashier.reactivated' : 'pos.cashier.deactivated',
|
||||||
|
id,
|
||||||
|
target.email,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return reply.send({
|
||||||
|
...updated.rows[0],
|
||||||
|
role: 'pos_cashier',
|
||||||
|
status: active ? 'active' : 'inactive',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete(
|
||||||
|
'/pos/users/:id',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['POS Admin'],
|
||||||
|
summary: 'Soft-delete a POS cashier while preserving history',
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: { id: { type: 'string', format: 'uuid' } },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
204: { type: 'null' },
|
||||||
|
401: errorSchema,
|
||||||
|
403: errorSchema,
|
||||||
|
404: errorSchema,
|
||||||
|
409: errorSchema,
|
||||||
|
},
|
||||||
|
} as FastifySchema,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const admin = await authenticate(request);
|
||||||
|
requireRole(admin, 'admin');
|
||||||
|
const { id } = parseJson(idParamSchema, request.params);
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const targetResult = await client.query<{ email: string; deleted_at: Date | null }>(
|
||||||
|
`SELECT email, deleted_at FROM backoffice_users
|
||||||
|
WHERE id = $1 AND role = 'pos_cashier' FOR UPDATE`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
const target = targetResult.rows[0];
|
||||||
|
if (!target) {
|
||||||
|
throw new AppError(404, 'POS_CASHIER_NOT_FOUND', 'Cajero no encontrado');
|
||||||
|
}
|
||||||
|
if (target.deleted_at) {
|
||||||
|
throw new AppError(409, 'POS_CASHIER_ALREADY_DELETED', 'El cajero ya está eliminado');
|
||||||
|
}
|
||||||
|
const open = await client.query(
|
||||||
|
`SELECT id FROM pos_cash_sessions
|
||||||
|
WHERE user_id = $1 AND status = 'OPEN' LIMIT 1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (open.rows[0]) {
|
||||||
|
throw new AppError(
|
||||||
|
409,
|
||||||
|
'POS_CASHIER_HAS_OPEN_SESSION',
|
||||||
|
'Cierra la sesión de caja antes de eliminar el cajero',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query(
|
||||||
|
`UPDATE backoffice_users
|
||||||
|
SET active = false,
|
||||||
|
deactivated_at = COALESCE(deactivated_at, now()),
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`UPDATE backoffice_sessions SET revoked_at = COALESCE(revoked_at, now())
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO security_audit_log (actor_id, action, target, metadata)
|
||||||
|
VALUES ($1, 'pos.cashier.deleted', $2, jsonb_build_object('email', $3::text))`,
|
||||||
|
[admin.id, id, target.email],
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return reply.code(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export class CashSessionNotOpenError extends PosError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CashierUnavailableError extends PosError {
|
||||||
|
constructor(userId: string) {
|
||||||
|
super('POS_CASHIER_UNAVAILABLE', `POS user ${userId} is inactive or deleted`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class TerminalNotBoundError extends PosError {
|
export class TerminalNotBoundError extends PosError {
|
||||||
constructor(terminalId: string) {
|
constructor(terminalId: string) {
|
||||||
super('TERMINAL_NOT_BOUND', `Terminal ${terminalId} is not bound`);
|
super('TERMINAL_NOT_BOUND', `Terminal ${terminalId} is not bound`);
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type pg from 'pg';
|
import type pg from 'pg';
|
||||||
import type { PosCashSessionRepository } from '../domain/ports.js';
|
import type { PosCashSessionRepository } from '../domain/ports.js';
|
||||||
import type { PosCashSession, OpenCashSessionInput, CloseCashSessionInput } from '../domain/cash-session.js';
|
import type {
|
||||||
|
PosCashSession,
|
||||||
|
OpenCashSessionInput,
|
||||||
|
CloseCashSessionInput,
|
||||||
|
} from '../domain/cash-session.js';
|
||||||
|
import { CashierUnavailableError } from '../domain/errors.js';
|
||||||
|
|
||||||
interface SessionRow {
|
interface SessionRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,21 +65,43 @@ export class PgCashSessionRepository implements PosCashSessionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async open(input: OpenCashSessionInput): Promise<PosCashSession> {
|
async open(input: OpenCashSessionInput): Promise<PosCashSession> {
|
||||||
// Get store_id from terminal
|
const client = await this.pool.connect();
|
||||||
const terminal = await this.pool.query<{ store_id: string }>(
|
try {
|
||||||
'SELECT store_id FROM pos_terminals WHERE id = $1',
|
await client.query('BEGIN');
|
||||||
[input.terminalId],
|
// This row lock serializes opening against cashier deactivation/deletion.
|
||||||
);
|
const cashier = await client.query<{ active: boolean; deleted_at: Date | null }>(
|
||||||
if (!terminal.rows[0]) throw new Error(`Terminal ${input.terminalId} not found`);
|
`SELECT active, deleted_at FROM backoffice_users
|
||||||
const storeId = terminal.rows[0].store_id;
|
WHERE id = $1 AND role IN ('admin', 'pos_manager', 'pos_cashier')
|
||||||
|
FOR UPDATE`,
|
||||||
|
[input.userId],
|
||||||
|
);
|
||||||
|
const cashierRow = cashier.rows[0];
|
||||||
|
if (!cashierRow?.active || cashierRow.deleted_at) {
|
||||||
|
throw new CashierUnavailableError(input.userId);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.pool.query<SessionRow>(
|
const terminal = await client.query<{ store_id: string }>(
|
||||||
`INSERT INTO pos_cash_sessions (terminal_id, store_id, user_id, opening_cash_cents)
|
'SELECT store_id FROM pos_terminals WHERE id = $1',
|
||||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
[input.terminalId],
|
||||||
[input.terminalId, storeId, input.userId, input.openingCashCents],
|
);
|
||||||
);
|
if (!terminal.rows[0]) throw new Error(`Terminal ${input.terminalId} not found`);
|
||||||
if (!result.rows[0]) throw new Error('Failed to create session');
|
const storeId = terminal.rows[0].store_id;
|
||||||
return toSession(result.rows[0]);
|
|
||||||
|
const result = await client.query<SessionRow>(
|
||||||
|
`INSERT INTO pos_cash_sessions (terminal_id, store_id, user_id, opening_cash_cents)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
|
[input.terminalId, storeId, input.userId, input.openingCashCents],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) throw new Error('Failed to create session');
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return toSession(row);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(input: CloseCashSessionInput): Promise<PosCashSession> {
|
async close(input: CloseCashSessionInput): Promise<PosCashSession> {
|
||||||
@@ -85,7 +112,13 @@ export class PgCashSessionRepository implements PosCashSessionRepository {
|
|||||||
closing_cash_cents = $2, actual_cash_cents = $3,
|
closing_cash_cents = $2, actual_cash_cents = $3,
|
||||||
difference_cents = $4, notes = $5, updated_at = now()
|
difference_cents = $4, notes = $5, updated_at = now()
|
||||||
WHERE id = $1 RETURNING *`,
|
WHERE id = $1 RETURNING *`,
|
||||||
[input.sessionId, input.closingCashCents, input.actualCashCents, difference, input.notes ?? null],
|
[
|
||||||
|
input.sessionId,
|
||||||
|
input.closingCashCents,
|
||||||
|
input.actualCashCents,
|
||||||
|
difference,
|
||||||
|
input.notes ?? null,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
if (!result.rows[0]) throw new Error(`Session ${input.sessionId} not found`);
|
if (!result.rows[0]) throw new Error(`Session ${input.sessionId} not found`);
|
||||||
return toSession(result.rows[0]);
|
return toSession(result.rows[0]);
|
||||||
|
|||||||
91
work/artifacts/F-187/architect.md
Normal file
91
work/artifacts/F-187/architect.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# F-187 — Architecture
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Model cashier removal as an account lifecycle on `backoffice_users`; never delete the row referenced by POS history.
|
||||||
|
|
||||||
|
Migration `055_pos_cashier_lifecycle.js` adds:
|
||||||
|
|
||||||
|
- `active boolean NOT NULL DEFAULT true`
|
||||||
|
- `deactivated_at timestamptz NULL`
|
||||||
|
- `deleted_at timestamptz NULL`
|
||||||
|
- consistency check: deleted implies inactive; active implies no lifecycle timestamps
|
||||||
|
- index over POS cashier role/status
|
||||||
|
|
||||||
|
Existing accounts remain active. Down removes only lifecycle columns/index/check.
|
||||||
|
|
||||||
|
## Semantics
|
||||||
|
|
||||||
|
| Action | Result | Reversible | Sessions |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| Deactivate | `active=false`, `deactivated_at=now()` | yes | revoke all |
|
||||||
|
| Reactivate | `active=true`, timestamps null | yes, unless deleted | remain revoked |
|
||||||
|
| Delete | `active=false`, `deleted_at=now()` | no | revoke all |
|
||||||
|
|
||||||
|
Delete is a tombstone rather than physical SQL deletion. The UUID and email remain available to historical receipt/session/reporting joins. Deleted cashiers are returned by the admin list with status `deleted`, but cannot be mutated again.
|
||||||
|
|
||||||
|
Only rows whose current role is `pos_cashier` can be targeted. Managers, editors and administrators remain out of scope.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints use backoffice authentication and `requireRole(admin)`.
|
||||||
|
|
||||||
|
- `GET /pos/users`: existing endpoint gains `active`, `deactivatedAt`, `deletedAt`, `status`; remains POS staff list-compatible.
|
||||||
|
- `POST /pos/users`: existing creation contract; lifecycle defaults active.
|
||||||
|
- `PATCH /pos/users/:id/status` body `{ "active": boolean }`: deactivate/reactivate cashier.
|
||||||
|
- `DELETE /pos/users/:id`: irreversible soft deletion, HTTP 204.
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
|
||||||
|
- `POS_CASHIER_NOT_FOUND` (404): target absent or not `pos_cashier`.
|
||||||
|
- `POS_CASHIER_HAS_OPEN_SESSION` (409): close register first.
|
||||||
|
- `POS_CASHIER_DELETED` (409): attempted status change on tombstone.
|
||||||
|
- `POS_CASHIER_ALREADY_DELETED` (409): repeat deletion.
|
||||||
|
|
||||||
|
Mutations use a transaction and lock the cashier row `FOR UPDATE`. They check open cash sessions before lifecycle mutation, revoke `backoffice_sessions`, and append `pos.cashier.deactivated`, `pos.cashier.reactivated` or `pos.cashier.deleted` to `security_audit_log` in the same transaction.
|
||||||
|
|
||||||
|
## Race safety
|
||||||
|
|
||||||
|
`PgCashSessionRepository.open` must also lock the target `backoffice_users` row inside its transaction and require `active=true AND deleted_at IS NULL` before inserting. This serializes cash-session opening against deactivation/deletion:
|
||||||
|
|
||||||
|
- open wins: lifecycle mutation sees the open session and returns 409;
|
||||||
|
- lifecycle wins: opening sees inactive/deleted and fails.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Defense in depth at both entry paths:
|
||||||
|
|
||||||
|
- `PgBackofficeUserRepository.findByEmail` only returns active, non-deleted accounts, so login gives the existing generic invalid-credentials response.
|
||||||
|
- `createBackofficeSessionAuthenticator` includes the same lifecycle predicate, so sessions are invalid even before revocation completes and after database restore/races.
|
||||||
|
- combined authentication inherits the backoffice check.
|
||||||
|
|
||||||
|
No account-state detail is exposed by login.
|
||||||
|
|
||||||
|
## Admin UI
|
||||||
|
|
||||||
|
Add a **Cajeros** section to the TPV admin page:
|
||||||
|
|
||||||
|
- create form (email/password) fixed to `pos_cashier`;
|
||||||
|
- table with email, status and creation date;
|
||||||
|
- active: Deactivate + Delete;
|
||||||
|
- inactive: Reactivate + Delete;
|
||||||
|
- deleted: no mutation actions;
|
||||||
|
- native explicit confirmations name the cashier and explain open-register/history behavior.
|
||||||
|
|
||||||
|
The admin page reloads cashier state independently from store-scoped terminal/payment configuration.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
PostgreSQL integration coverage:
|
||||||
|
|
||||||
|
1. migration defaults existing cashier active;
|
||||||
|
2. non-admin receives 403;
|
||||||
|
3. deactivation revokes sessions and blocks authentication/login lookup;
|
||||||
|
4. reactivation works without restoring revoked sessions;
|
||||||
|
5. open cash session blocks deactivation and deletion;
|
||||||
|
6. deletion keeps the same cashier row and historical `pos_cash_sessions.user_id` join;
|
||||||
|
7. deleted cashier cannot reactivate;
|
||||||
|
8. non-cashier target behaves as not found;
|
||||||
|
9. migration up/no-op/down/up remains green.
|
||||||
|
|
||||||
|
Targeted typecheck/build covers admin UI contract.
|
||||||
12
work/artifacts/F-187/documenter.md
Normal file
12
work/artifacts/F-187/documenter.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# F-187 — Documentation
|
||||||
|
|
||||||
|
Updated `docs/pos/POS_CHECKOUT.md` with:
|
||||||
|
|
||||||
|
- cashier active/inactive/deleted semantics;
|
||||||
|
- session revocation and fresh-login behavior;
|
||||||
|
- irreversible soft deletion and historical-attribution guarantee;
|
||||||
|
- mandatory cash-session close before removal;
|
||||||
|
- admin lifecycle API endpoints and role restrictions;
|
||||||
|
- audit event behavior.
|
||||||
|
|
||||||
|
Removed F-187 from the future-work list now that the lifecycle is implemented.
|
||||||
36
work/artifacts/F-187/implementer.md
Normal file
36
work/artifacts/F-187/implementer.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# F-187 — Implementer evidence
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
- Added reversible migration `055_pos_cashier_lifecycle.js` with active/deactivated/deleted account lifecycle, consistency constraint and cashier status index.
|
||||||
|
- Extended backoffice domain roles to include POS manager/cashier and lifecycle fields.
|
||||||
|
- Blocked inactive/deleted accounts in both credential lookup and live session authentication.
|
||||||
|
- Made cash-session opening transactional and serialized against lifecycle mutation using the same `backoffice_users ... FOR UPDATE` row lock.
|
||||||
|
- Extended admin-only POS user list with lifecycle status.
|
||||||
|
- Added admin-only cashier activate/deactivate and soft-delete endpoints.
|
||||||
|
- Lifecycle mutations reject open cash sessions, revoke every live session, preserve the cashier row/UUID and write an atomic security audit event.
|
||||||
|
- Added TPV admin cashier UI for create, status display, confirmed deactivate/reactivate and irreversible deletion.
|
||||||
|
- Added real PostgreSQL integration coverage in `pos-cashier-lifecycle.itest.ts`.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Backend typecheck: PASS.
|
||||||
|
- Admin typecheck: PASS.
|
||||||
|
- Backend production build: PASS.
|
||||||
|
- Admin production build: PASS (only pre-existing Turbopack upload tracing warnings).
|
||||||
|
- Targeted ESLint for every changed TypeScript/TSX file: PASS.
|
||||||
|
- Prettier for every changed source/migration/test file: PASS.
|
||||||
|
- POS unit tests: 12/12 PASS.
|
||||||
|
- Full unit suite without DB: 268/268 PASS.
|
||||||
|
- F-187 PostgreSQL integration: 4/4 PASS.
|
||||||
|
- Full real-PostgreSQL sequential suite: 354/354 PASS across 79 files.
|
||||||
|
- Migration fresh up / second no-op / full down / re-up: 4/4 PASS.
|
||||||
|
- `git diff --check`: PASS.
|
||||||
|
- `./scripts/verify.sh`: PASS.
|
||||||
|
|
||||||
|
## Repository baseline notes
|
||||||
|
|
||||||
|
- Full backend lint remains red on 9 pre-existing errors in thumbnail script, log broadcaster, an old POS test, and reporting files; all F-187 changed files pass targeted ESLint and Prettier.
|
||||||
|
- Boundary check retains the single pre-existing security-module import violation; F-187 adds no module-boundary violation.
|
||||||
|
- Full admin lint has zero errors and 23 pre-existing warnings.
|
||||||
|
- Existing untracked upload JPGs were not touched.
|
||||||
20
work/artifacts/F-187/leader-close.json
Normal file
20
work/artifacts/F-187/leader-close.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-187",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "reviewer/security/qa gates APPROVED", "ok": true },
|
||||||
|
{ "item": "354/354 tests with real PostgreSQL sequential", "ok": true },
|
||||||
|
{ "item": "migration up/no-op/down/up", "ok": true },
|
||||||
|
{ "item": "backend and admin typecheck/build", "ok": true },
|
||||||
|
{ "item": "targeted changed-file lint and formatting", "ok": true },
|
||||||
|
{ "item": "verify.sh final exit 0", "ok": true },
|
||||||
|
{ "item": "operator/API documentation updated", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": [],
|
||||||
|
"notes": [
|
||||||
|
"Unrelated upload JPGs are excluded from the feature commit.",
|
||||||
|
"Global backend lint debt and one security boundary violation predate F-187; changed files are clean."
|
||||||
|
]
|
||||||
|
}
|
||||||
33
work/artifacts/F-187/qa.json
Normal file
33
work/artifacts/F-187/qa.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-187",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"acceptance": [
|
||||||
|
{ "id": 1, "criterion": "Admin TPV lists cashier lifecycle status", "ok": true, "evidence": "GET /pos/users real-DB assertion and admin status badges" },
|
||||||
|
{ "id": 2, "criterion": "Admin creates active cashier", "ok": true, "evidence": "Fixed-role create form and server default/response active" },
|
||||||
|
{ "id": 3, "criterion": "Deactivate and reactivate non-deleted cashier", "ok": true, "evidence": "PATCH lifecycle integration assertions" },
|
||||||
|
{ "id": 4, "criterion": "Deactivate revokes and blocks current/future auth", "ok": true, "evidence": "DB revoked_at, old-cookie 401 and login 401 assertions" },
|
||||||
|
{ "id": 5, "criterion": "Confirmed deletion is irreversible", "ok": true, "evidence": "Explicit UI warning, DELETE 204 and reactivation 409" },
|
||||||
|
{ "id": 6, "criterion": "Open cash session blocks removal", "ok": true, "evidence": "PATCH and DELETE both return POS_CASHIER_HAS_OPEN_SESSION" },
|
||||||
|
{ "id": 7, "criterion": "Historical attribution survives deletion", "ok": true, "evidence": "Post-delete join keeps cashier ID through pos_cash_sessions" },
|
||||||
|
{ "id": 8, "criterion": "Admin-only and cashier-role-only", "ok": true, "evidence": "Cashier list 403 and manager target 404" },
|
||||||
|
{ "id": 9, "criterion": "Migration reversible with active default", "ok": true, "evidence": "Fresh/no-op/down/re-up 4/4 and inserted accounts default active" },
|
||||||
|
{ "id": 10, "criterion": "Regression and builds green", "ok": true, "evidence": "354/354 real-DB tests, typechecks, backend/admin builds, verify.sh" }
|
||||||
|
],
|
||||||
|
"regression": {
|
||||||
|
"unit_without_db": "268 passed",
|
||||||
|
"pos_unit": "12 passed",
|
||||||
|
"f187_real_postgresql": "4 passed",
|
||||||
|
"real_postgresql_sequential": "354 passed across 79 files",
|
||||||
|
"migration_cycle": "4 passed",
|
||||||
|
"backend_build": "passed",
|
||||||
|
"admin_build": "passed",
|
||||||
|
"verify": "passed"
|
||||||
|
},
|
||||||
|
"issues": [],
|
||||||
|
"notes": [
|
||||||
|
"Production admin UI was compile/type/lint validated; no browser automation harness exists for native confirm dialogs.",
|
||||||
|
"Backend global lint baseline remains red outside changed files; targeted F-187 lint is green."
|
||||||
|
]
|
||||||
|
}
|
||||||
23
work/artifacts/F-187/reviewer.json
Normal file
23
work/artifacts/F-187/reviewer.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-187",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "Soft deletion preserves backoffice user UUID and historical joins", "ok": true },
|
||||||
|
{ "item": "Lifecycle check constraint permits only coherent active/inactive/deleted states", "ok": true },
|
||||||
|
{ "item": "Login lookup and live session authentication both reject unavailable accounts", "ok": true },
|
||||||
|
{ "item": "Deactivation/deletion revokes sessions in the lifecycle transaction", "ok": true },
|
||||||
|
{ "item": "Cash-session opening and lifecycle mutations serialize on the same user row lock", "ok": true },
|
||||||
|
{ "item": "Open cash sessions block deactivation/deletion", "ok": true },
|
||||||
|
{ "item": "Only admin can mutate and only pos_cashier rows can be targeted", "ok": true },
|
||||||
|
{ "item": "Lifecycle audit event is atomic with each successful mutation", "ok": true },
|
||||||
|
{ "item": "Admin UI exposes explicit statuses and confirmations", "ok": true },
|
||||||
|
{ "item": "Migration, integration regression, typecheck and builds pass", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": [],
|
||||||
|
"notes": [
|
||||||
|
"Deleted cashier email is intentionally retained and remains unique to keep historical receipts human-readable; recreating the same address is not supported.",
|
||||||
|
"Manager/editor/admin lifecycle remains out of F-187 scope."
|
||||||
|
]
|
||||||
|
}
|
||||||
29
work/artifacts/F-187/security.json
Normal file
29
work/artifacts/F-187/security.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-187",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "Cashier list/create/status/delete require admin role", "ok": true },
|
||||||
|
{ "item": "Mutation lookup is restricted to pos_cashier targets", "ok": true },
|
||||||
|
{ "item": "Every lifecycle input and UUID is schema validated", "ok": true },
|
||||||
|
{ "item": "All lifecycle SQL uses bound parameters", "ok": true },
|
||||||
|
{ "item": "Deactivation/deletion revokes all live sessions atomically", "ok": true },
|
||||||
|
{ "item": "Authenticator independently rejects inactive/deleted users", "ok": true },
|
||||||
|
{ "item": "Credential lookup preserves generic anti-enumeration failure", "ok": true },
|
||||||
|
{ "item": "Cash-session opening race is serialized by row lock", "ok": true },
|
||||||
|
{ "item": "Open-register guard prevents abandoning accountable cash", "ok": true },
|
||||||
|
{ "item": "Security audit events record actor/action/target atomically", "ok": true },
|
||||||
|
{ "item": "Changed-diff secret scan", "ok": true },
|
||||||
|
{ "item": "Backend and admin production dependency audit", "ok": true }
|
||||||
|
],
|
||||||
|
"dependency_audit": {
|
||||||
|
"backend": "0 vulnerabilities",
|
||||||
|
"admin": "0 vulnerabilities"
|
||||||
|
},
|
||||||
|
"issues": [],
|
||||||
|
"notes": [
|
||||||
|
"Soft deletion intentionally retains cashier email for human-readable historical attribution; this is account removal, not a personal-data erasure workflow.",
|
||||||
|
"No user-supplied audit metadata is accepted."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,32 +1,31 @@
|
|||||||
# F-186 — POS configurable checkout, mixed payments and receipts
|
# F-187 — Admin can deactivate and delete POS cashiers
|
||||||
|
|
||||||
Complete the TPV cashier flow for touch terminals and self-payment use cases.
|
Allow administrators to safely remove cashier access without breaking historical POS attribution.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
- Increase configurable quick products from 6 to 8.
|
- Add an explicit active/deactivated/deleted lifecycle for backoffice POS cashier accounts.
|
||||||
- Replace line discount text/inline interaction with a touch-sized button; allow admins to disable line discounts per terminal.
|
- Show POS cashiers and their status in the TPV administration page.
|
||||||
- Let admins configure and enable payment methods (cash, card, Bizum, Stripe, Apple Pay, or another named method).
|
- Let admins create cashiers, deactivate/reactivate them, and delete them with explicit confirmation.
|
||||||
- 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.
|
- Treat delete as an irreversible soft deletion: preserve the backoffice user row and its ID so sessions, sales, receipts, reporting and audit history keep their cashier attribution.
|
||||||
- For cash, accept tendered amount above the outstanding amount and calculate change.
|
- Revoke every live backoffice session when a cashier is deactivated or deleted.
|
||||||
- Require an explicit final confirmation after payment allocation before closing the sale.
|
- Reject login and existing-session authentication for inactive or deleted accounts.
|
||||||
- Generate a receipt with company identity, date/time, configurable ticket numbering, item name, quantity, subtotal, totals, payment methods/amounts, cash change, and return policy.
|
- Reject deactivation/deletion while the cashier owns an open cash session; require the cash session to be closed first.
|
||||||
- Offer print and email delivery. Clear the cashier only after print/email action succeeds or is explicitly completed.
|
- Keep all lifecycle mutations admin-only and cashier-role-only.
|
||||||
- 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
|
## Out of scope
|
||||||
- Real integrations with external payment processors.
|
- Removing or changing administrators, editors or POS managers.
|
||||||
- Certified fiscal-printer protocols or country-specific fiscal certification.
|
- Reassigning historical sales or cash sessions to another cashier.
|
||||||
- Hardware-specific printer drivers; browser print is sufficient.
|
- Forcing or automating cash-session closure.
|
||||||
|
- Bulk cashier operations.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
1. Admin can configure up to eight quick products and POS renders all configured slots.
|
1. Admin TPV lists POS cashiers with active, inactive or deleted status.
|
||||||
2. Line discount is a touch target and is absent/blocked when the terminal disables discounts.
|
2. Admin can create a cashier and the account is active by default.
|
||||||
3. Enabled admin payment methods appear in POS; disabled methods cannot be submitted.
|
3. Admin can deactivate an active cashier and reactivate an inactive non-deleted cashier.
|
||||||
4. Cashier supports full and partial payment allocations, displays paid/remaining totals, and permits mixed methods.
|
4. Deactivation immediately revokes existing sessions and blocks future login/authentication.
|
||||||
5. Cash tender above the remaining total displays and records change; non-cash overpayment is rejected.
|
5. Admin can delete a cashier only after explicit confirmation; deleted cashiers cannot be reactivated or authenticate.
|
||||||
6. A sale can only be confirmed when allocations cover the exact total, and requires explicit confirmation.
|
6. Deactivation or deletion is rejected while the cashier has an open cash session.
|
||||||
7. Receipt contains all requested company, numbering, line, total, payment, change, and return-policy data.
|
7. Deletion preserves the cashier row/ID and all historical session, sale, receipt and reporting attribution.
|
||||||
8. Receipt supports browser print and email delivery; cashier resets only after delivery completion.
|
8. Non-admin users cannot list or mutate cashier lifecycle, and non-cashier roles cannot be targeted.
|
||||||
9. A free item can be added with name and positive price and does not reserve or decrement stock.
|
9. Migration is reversible and existing backoffice accounts remain active.
|
||||||
10. Existing POS sale/reporting contracts remain compatible and tests plus `verify.sh` are green.
|
10. Tests, typecheck, affected builds and `verify.sh` are green.
|
||||||
|
|||||||
@@ -495,3 +495,10 @@
|
|||||||
- Integridad: venta `COMPLETED`, stock/pagos/reporting/caja/secuencia atómicos e idempotentes; precio de catálogo y métodos validados en backend.
|
- 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/`.
|
- Evidencia: 350/350 tests con PostgreSQL real en secuencia, builds backend/POS/admin verdes; `work/artifacts/F-186/`.
|
||||||
- Seguimiento solicitado: F-187..F-193.
|
- Seguimiento solicitado: F-187..F-193.
|
||||||
|
|
||||||
|
## F-187 cerrada (2026-08-22) — Admin can deactivate and delete POS cashiers
|
||||||
|
- Gates: reviewer APPROVED, security APPROVED, qa APPROVED, verify.sh exit 0.
|
||||||
|
- Entregable: alta/listado de cajeros en Admin TPV, desactivación/reactivación y baja lógica irreversible con estados visibles.
|
||||||
|
- Seguridad: login y sesiones bloquean cuentas inactivas/eliminadas; revocación y auditoría atómicas; sesión de caja abierta impide la baja.
|
||||||
|
- Integridad: la fila/UUID se conserva para atribución histórica y la apertura de caja se serializa con la baja mediante bloqueo de fila.
|
||||||
|
- Evidencia: 354/354 tests con PostgreSQL real en secuencia, migración up/no-op/down/up y builds backend/admin verdes; `work/artifacts/F-187/`.
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
"state": "waiting",
|
"state": "waiting",
|
||||||
"next_agent": "leader",
|
"next_agent": "leader",
|
||||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||||
"updated_at": "2026-08-22T20:08:30Z",
|
"updated_at": "2026-08-22T20:23:50Z",
|
||||||
"timeline": []
|
"timeline": []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user