diff --git a/backlog/features.json b/backlog/features.json index 3ea2268..dd3e187 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -7223,13 +7223,15 @@ "description": "Allow safe cashier removal from admin while preserving historical sale and session attribution.", "priority": "high", "risk": "med", - "status": "pending", + "status": "done", "created_at": "2026-08-22", "gates": { - "reviewer": false, - "security": false, - "qa": false - } + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-22T20:23:44Z" }, { "id": "F-188", diff --git a/docs/pos/POS_CHECKOUT.md b/docs/pos/POS_CHECKOUT.md index 775ba00..a36f821 100644 --- a/docs/pos/POS_CHECKOUT.md +++ b/docs/pos/POS_CHECKOUT.md @@ -1,6 +1,6 @@ # 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 @@ -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. +### 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 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 -- 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. diff --git a/project/apps/admin/src/app/(dashboard)/pos/page.tsx b/project/apps/admin/src/app/(dashboard)/pos/page.tsx index e25992d..aaf2fca 100644 --- a/project/apps/admin/src/app/(dashboard)/pos/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/pos/page.tsx @@ -35,6 +35,16 @@ interface PaymentMethod { active: boolean; 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 { storeId: string; name: string; @@ -85,6 +95,10 @@ export default function PosAdminPage() { const [savingTouch, setSavingTouch] = useState(false); const [touchMessage, setTouchMessage] = useState(''); const [paymentMethods, setPaymentMethods] = useState([]); + const [cashiers, setCashiers] = useState([]); + const [newCashier, setNewCashier] = useState({ email: '', password: '' }); + const [savingCashier, setSavingCashier] = useState(false); + const [cashierMessage, setCashierMessage] = useState(''); const [newMethod, setNewMethod] = useState({ code: '', label: '', @@ -118,14 +132,16 @@ export default function PosAdminPage() { setLoading(true); setError(''); 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<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'), api.get<{ items: CatalogOption[] }>('/api/pos/admin/catalog-products'), + api.get<{ items: Array }>('/api/pos/users'), ]); setStores(storeData.stores); setTerminals(terminalData.terminals); 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 || ''; setStoreId(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) => { event.preventDefault(); setSavingReceipt(true); @@ -362,6 +429,116 @@ export default function PosAdminPage() { )} +
+
+

Cajeros

+

+ Desactivar corta el acceso y permite reactivarlo. Eliminar es irreversible, pero + conserva la atribución histórica de ventas y sesiones de caja. +

+
+
+ + + +
+
+ + + + + + + + + + + {cashiers.map((cashier) => ( + + + + + + + ))} + {!loading && cashiers.length === 0 && ( + + + + )} + +
EmailEstadoCreadoAcciones
{cashier.email} + + {cashier.status === 'active' + ? 'Activo' + : cashier.status === 'inactive' + ? 'Inactivo' + : 'Eliminado'} + + + {new Date(cashier.createdAt).toLocaleDateString('es-ES')} + + {cashier.status !== 'deleted' && ( +
+ + +
+ )} +
+ No hay cajeros configurados. +
+
+ {cashierMessage &&

{cashierMessage}

} +
+ {configuring && (
diff --git a/project/migrations/055_pos_cashier_lifecycle.js b/project/migrations/055_pos_cashier_lifecycle.js new file mode 100644 index 0000000..66cdcd5 --- /dev/null +++ b/project/migrations/055_pos_cashier_lifecycle.js @@ -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, + }); +}; diff --git a/project/src/app/tests/pos-cashier-lifecycle.itest.ts b/project/src/app/tests/pos-cashier-lifecycle.itest.ts new file mode 100644 index 0000000..65e7520 --- /dev/null +++ b/project/src/app/tests/pos-cashier-lifecycle.itest.ts @@ -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>; + + 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', + ]); + }); +}); diff --git a/project/src/modules/backoffice/api/backoffice.routes.ts b/project/src/modules/backoffice/api/backoffice.routes.ts index e9a76fa..e996ab4 100644 --- a/project/src/modules/backoffice/api/backoffice.routes.ts +++ b/project/src/modules/backoffice/api/backoffice.routes.ts @@ -71,7 +71,7 @@ export async function registerBackofficeRoutes( properties: { id: { type: 'string', format: 'uuid' }, email: { type: 'string', format: 'email' }, - role: { type: 'string', enum: ['admin', 'editor'] }, + role: { type: 'string', enum: ['admin', 'editor', 'pos_manager', 'pos_cashier'] }, }, }, 401: errorSchema, @@ -97,7 +97,7 @@ export async function registerBackofficeRoutes( properties: { id: { type: 'string', format: 'uuid' }, 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' } } }, diff --git a/project/src/modules/backoffice/domain/backoffice-user.ts b/project/src/modules/backoffice/domain/backoffice-user.ts index 80e62d9..5baf5a2 100644 --- a/project/src/modules/backoffice/domain/backoffice-user.ts +++ b/project/src/modules/backoffice/domain/backoffice-user.ts @@ -3,13 +3,16 @@ * Backoffice users (admin/editor) are physically separated from storefront * 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 { id: string; email: string; role: BackofficeRole; mfaEnrolled: boolean; + active: boolean; + deactivatedAt: Date | null; + deletedAt: Date | null; createdAt: Date; updatedAt: Date; } diff --git a/project/src/modules/backoffice/infrastructure/backoffice-session-authenticator.ts b/project/src/modules/backoffice/infrastructure/backoffice-session-authenticator.ts index c13ce66..086ac32 100644 --- a/project/src/modules/backoffice/infrastructure/backoffice-session-authenticator.ts +++ b/project/src/modules/backoffice/infrastructure/backoffice-session-authenticator.ts @@ -4,7 +4,7 @@ */ import type { FastifyRequest } from 'fastify'; 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 { hashBackofficeToken } from './backoffice-session-token.js'; import { BACKOFFICE_SESSION_COOKIE_NAME } from '../api/backoffice.routes.js'; @@ -22,6 +22,8 @@ const RESOLVE_SQL = ` WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() + AND u.active = true + AND u.deleted_at IS NULL `; export function createBackofficeSessionAuthenticator(pool: pg.Pool): Authenticate { @@ -31,7 +33,7 @@ export function createBackofficeSessionAuthenticator(pool: pg.Pool): Authenticat const result = await pool.query(RESOLVE_SQL, [hashBackofficeToken(token)]); const row = result.rows[0]; 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 }; }; } diff --git a/project/src/modules/backoffice/infrastructure/pg-backoffice-user-repository.ts b/project/src/modules/backoffice/infrastructure/pg-backoffice-user-repository.ts index 8622b1a..a3901ff 100644 --- a/project/src/modules/backoffice/infrastructure/pg-backoffice-user-repository.ts +++ b/project/src/modules/backoffice/infrastructure/pg-backoffice-user-repository.ts @@ -14,6 +14,9 @@ interface UserRow { password_hash: string; role: string; mfa_enrolled: boolean; + active: boolean; + deactivated_at: Date | null; + deleted_at: Date | null; created_at: Date; updated_at: Date; } @@ -47,7 +50,8 @@ export class PgBackofficeUserRepository implements BackofficeUserRepository { async findByEmail(email: string): Promise { const result = await this.pool.query( - 'SELECT * FROM backoffice_users WHERE email = $1', + `SELECT * FROM backoffice_users + WHERE email = $1 AND active = true AND deleted_at IS NULL`, [email.toLowerCase()], ); const row = result.rows[0]; @@ -76,6 +80,9 @@ function toUser(row: UserRow): BackofficeUser { email: row.email, role: row.role as BackofficeRole, mfaEnrolled: row.mfa_enrolled, + active: row.active, + deactivatedAt: row.deactivated_at, + deletedAt: row.deleted_at, createdAt: row.created_at, updatedAt: row.updated_at, }; diff --git a/project/src/modules/pos/api/pos.routes.ts b/project/src/modules/pos/api/pos.routes.ts index 80a4a00..200f1c0 100644 --- a/project/src/modules/pos/api/pos.routes.ts +++ b/project/src/modules/pos/api/pos.routes.ts @@ -2190,8 +2190,17 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps const user = await authenticate(request); requireRole(user, 'admin'); const result = await pool.query( - `SELECT id, email, role FROM backoffice_users - WHERE role IN ('pos_manager','pos_cashier') ORDER BY email`, + `SELECT id, email, role, active, + 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 }); }, @@ -2246,7 +2255,212 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps ); 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 }); + 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(); + } }, ); diff --git a/project/src/modules/pos/domain/errors.ts b/project/src/modules/pos/domain/errors.ts index 220b02c..2cb2a76 100644 --- a/project/src/modules/pos/domain/errors.ts +++ b/project/src/modules/pos/domain/errors.ts @@ -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 { constructor(terminalId: string) { super('TERMINAL_NOT_BOUND', `Terminal ${terminalId} is not bound`); diff --git a/project/src/modules/pos/infrastructure/pg-cash-session-repository.ts b/project/src/modules/pos/infrastructure/pg-cash-session-repository.ts index f4ff9f0..6dbcbe4 100644 --- a/project/src/modules/pos/infrastructure/pg-cash-session-repository.ts +++ b/project/src/modules/pos/infrastructure/pg-cash-session-repository.ts @@ -1,6 +1,11 @@ import type pg from 'pg'; 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 { id: string; @@ -60,21 +65,43 @@ export class PgCashSessionRepository implements PosCashSessionRepository { } async open(input: OpenCashSessionInput): Promise { - // Get store_id from terminal - const terminal = await this.pool.query<{ store_id: string }>( - 'SELECT store_id FROM pos_terminals WHERE id = $1', - [input.terminalId], - ); - if (!terminal.rows[0]) throw new Error(`Terminal ${input.terminalId} not found`); - const storeId = terminal.rows[0].store_id; + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + // This row lock serializes opening against cashier deactivation/deletion. + const cashier = await client.query<{ active: boolean; deleted_at: Date | null }>( + `SELECT active, deleted_at FROM backoffice_users + 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( - `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], - ); - if (!result.rows[0]) throw new Error('Failed to create session'); - return toSession(result.rows[0]); + const terminal = await client.query<{ store_id: string }>( + 'SELECT store_id FROM pos_terminals WHERE id = $1', + [input.terminalId], + ); + if (!terminal.rows[0]) throw new Error(`Terminal ${input.terminalId} not found`); + const storeId = terminal.rows[0].store_id; + + const result = await client.query( + `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 { @@ -85,7 +112,13 @@ export class PgCashSessionRepository implements PosCashSessionRepository { closing_cash_cents = $2, actual_cash_cents = $3, difference_cents = $4, notes = $5, updated_at = now() 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`); return toSession(result.rows[0]); diff --git a/work/artifacts/F-187/architect.md b/work/artifacts/F-187/architect.md new file mode 100644 index 0000000..4ad09b5 --- /dev/null +++ b/work/artifacts/F-187/architect.md @@ -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. diff --git a/work/artifacts/F-187/documenter.md b/work/artifacts/F-187/documenter.md new file mode 100644 index 0000000..e0e2329 --- /dev/null +++ b/work/artifacts/F-187/documenter.md @@ -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. diff --git a/work/artifacts/F-187/implementer.md b/work/artifacts/F-187/implementer.md new file mode 100644 index 0000000..0c26d79 --- /dev/null +++ b/work/artifacts/F-187/implementer.md @@ -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. diff --git a/work/artifacts/F-187/leader-close.json b/work/artifacts/F-187/leader-close.json new file mode 100644 index 0000000..ec1f764 --- /dev/null +++ b/work/artifacts/F-187/leader-close.json @@ -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." + ] +} diff --git a/work/artifacts/F-187/qa.json b/work/artifacts/F-187/qa.json new file mode 100644 index 0000000..98794d4 --- /dev/null +++ b/work/artifacts/F-187/qa.json @@ -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." + ] +} diff --git a/work/artifacts/F-187/reviewer.json b/work/artifacts/F-187/reviewer.json new file mode 100644 index 0000000..36c4dce --- /dev/null +++ b/work/artifacts/F-187/reviewer.json @@ -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." + ] +} diff --git a/work/artifacts/F-187/security.json b/work/artifacts/F-187/security.json new file mode 100644 index 0000000..3387cdb --- /dev/null +++ b/work/artifacts/F-187/security.json @@ -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." + ] +} diff --git a/work/current.md b/work/current.md index d560356..b8599f2 100644 --- a/work/current.md +++ b/work/current.md @@ -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 -- 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. +- Add an explicit active/deactivated/deleted lifecycle for backoffice POS cashier accounts. +- Show POS cashiers and their status in the TPV administration page. +- Let admins create cashiers, deactivate/reactivate them, and delete them with explicit confirmation. +- 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. +- Revoke every live backoffice session when a cashier is deactivated or deleted. +- Reject login and existing-session authentication for inactive or deleted accounts. +- Reject deactivation/deletion while the cashier owns an open cash session; require the cash session to be closed first. +- Keep all lifecycle mutations admin-only and cashier-role-only. ## 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. +- Removing or changing administrators, editors or POS managers. +- Reassigning historical sales or cash sessions to another cashier. +- Forcing or automating cash-session closure. +- Bulk cashier operations. ## 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. +1. Admin TPV lists POS cashiers with active, inactive or deleted status. +2. Admin can create a cashier and the account is active by default. +3. Admin can deactivate an active cashier and reactivate an inactive non-deleted cashier. +4. Deactivation immediately revokes existing sessions and blocks future login/authentication. +5. Admin can delete a cashier only after explicit confirmation; deleted cashiers cannot be reactivated or authenticate. +6. Deactivation or deletion is rejected while the cashier has an open cash session. +7. Deletion preserves the cashier row/ID and all historical session, sale, receipt and reporting attribution. +8. Non-admin users cannot list or mutate cashier lifecycle, and non-cashier roles cannot be targeted. +9. Migration is reversible and existing backoffice accounts remain active. +10. Tests, typecheck, affected builds and `verify.sh` are green. diff --git a/work/history.md b/work/history.md index 8795686..8765951 100644 --- a/work/history.md +++ b/work/history.md @@ -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. - Evidencia: 350/350 tests con PostgreSQL real en secuencia, builds backend/POS/admin verdes; `work/artifacts/F-186/`. - 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/`. diff --git a/work/runtime-status.json b/work/runtime-status.json index abd1240..a2180a0 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -6,6 +6,6 @@ "state": "waiting", "next_agent": "leader", "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": [] }