feat(F-187): completed feature

This commit is contained in:
chattie
2026-08-22 22:23:44 +02:00
parent a3f6edd325
commit ca12f46bff
22 changed files with 1053 additions and 59 deletions

View File

@@ -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",

View File

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

View File

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

View 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,
});
};

View 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',
]);
});
});

View File

@@ -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' } } },

View File

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

View File

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

View File

@@ -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,
}; };

View File

@@ -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();
}
}, },
); );

View File

@@ -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`);

View File

@@ -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]);

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

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

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

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View File

@@ -1,32 +1,31 @@
# F-186POS configurable checkout, mixed payments and receipts # F-187Admin 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.

View File

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

View File

@@ -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": []
} }