4.0 KiB
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 truedeactivated_at timestamptz NULLdeleted_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 gainsactive,deactivatedAt,deletedAt,status; remains POS staff list-compatible.POST /pos/users: existing creation contract; lifecycle defaults active.PATCH /pos/users/:id/statusbody{ "active": boolean }: deactivate/reactivate cashier.DELETE /pos/users/:id: irreversible soft deletion, HTTP 204.
Errors:
POS_CASHIER_NOT_FOUND(404): target absent or notpos_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.findByEmailonly returns active, non-deleted accounts, so login gives the existing generic invalid-credentials response.createBackofficeSessionAuthenticatorincludes 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:
- migration defaults existing cashier active;
- non-admin receives 403;
- deactivation revokes sessions and blocks authentication/login lookup;
- reactivation works without restoring revoked sessions;
- open cash session blocks deactivation and deletion;
- deletion keeps the same cashier row and historical
pos_cash_sessions.user_idjoin; - deleted cashier cannot reactivate;
- non-cashier target behaves as not found;
- migration up/no-op/down/up remains green.
Targeted typecheck/build covers admin UI contract.