# HR + Finance Playwright e2e — Phase 0 map and plan > Phase 0 deliverable for `docs/prompts/hr-finance-e2e-prompt.md`. **Approved by the owner > 2026-08-24.** > > Reconstructed after the 2026-08-24 data-destruction incident > (`SECURITY-INCIDENT-2026-08-24.md`); the original was lost with the working tree. Content is > unchanged from the approved version. --- ## 1. The database decision **Recommendation: use `smart_office_e2e`.** It already exists on this machine — a schema clone of `smart_office_prod` (verified): | | `smart_office_prod` | `smart_office_e2e` | |---|---|---| | Size | 1483 MB | 1484 MB | | `iam.users` | 34,637 | 34,639 | | `iam.employees` | 2,431 | 2,433 | | `iam.positions` | 1,043 | 1,043 | | Schemas | `iam`, `freight`, `passenger`, `edr_payment`, `audit`, `hr`, `finance` | same six | | `hr` tables | 38 | 38 | | `finance` tables | 22 | 22 | This answers the prompt's own suggested option ("clone `smart_office_prod` into a `smart_office_e2e` database and run migrations + HR/Finance seeds there"). Its origin was traced to an earlier session at 2026-08-24 01:22, which also left a hand-made `e2e_hr_employee` role behind (since retired — see §1.1). ### 1.1 Seed HR and Finance into it — with the scoped seeder, not the vendor one Neither module's IAM rows existed in either database (`iam.application` had no `hr` or `finance` row; 0 of 9 roles). **Do not** run `pnpm run seed:hr`: it re-seeds IAM's entire baseline and aborts the whole transaction on an id conflict with a pre-existing row — ``` "people_operation" is id 9f9c0c2e-… in the database but the seed assigns it id 7bbf4e76-… ``` — which reproduces identically on any database restored from a dump taken at a different `@tria-plc/iamapi-common` version. That refusal is correct (it protects rows another product owns), but it means HR's and Finance's own rows can never be written that way. Use `scripts/seed-module-permissions.cjs` instead, which writes only the module's own rows by the stable ids its registry declares: ```bash DB_NAME=smart_office_e2e DB_PASSWORD=… node scripts/seed-module-permissions.cjs hr DB_NAME=smart_office_e2e DB_PASSWORD=… node scripts/seed-module-permissions.cjs finance ``` It carries a guard that has already fired for real: a key that exists under a *different* id stops the write rather than silently diverging. That caught five HR permission keys duplicated by the earlier session's hand-made `e2e_hr_employee` role. Those five rows and that role were retired in favour of the product's own `employee_self_service`, which is a strict superset (10 self-service keys vs 5). ### 1.2 Create every fixture inside the Active railway org 13 of 15 `iam.organizations` rows are `status='Debarred'`, and IAM's login SQL joins `o.status = $2` when building the session's `employee` array — so an employee in a Debarred org gets an **empty employee array and no organization context**, and every HR/Finance write plus most reads then 403 with "no organization context". **This is correct data, not a bug** — the counts make that clear: | org | status | employees | positions | |---|---|---|---| | Ethio Djibouti Standard Gauge Railway Share Company | Active | 2,416 | 1,004 | | TRIA | Active | 1 | 0 | | EDR | Debarred | 7 | 8 | | Nifas Silk-Lafto Sub City | Debarred | 2 | 0 | | EDR Freight | Debarred | 1 | 10 | | 10 further Addis Ababa sub-cities + "Endode EDR" | Debarred | 0 | 0 | **2,417 of 2,433 employees are in Active orgs.** The Debarred rows are Addis Ababa sub-cities inherited from the Smart Office lineage (this database is a `smart_office` replica — those were never EDR tenants) plus rows named after **applications** rather than legal entities. Note `iam.application` separately and correctly holds `edr_freight_app` / `edr_passenger_app` — "EDR Freight" and "EDR" as *organization* rows are artifacts, and near-empty ones. The practical rule: **seed fixtures exclusively inside the Active railway org**, which has all the real structure (2,416 employees, 1,004 positions, 7-level position hierarchy) a test could need. No org status needs changing, in prod or in the e2e clone. `seed-personas.cjs` asserts the org is Active and refuses to run otherwise. ### 1.3 Running the suite against it Point both APIs at `smart_office_e2e` **for the test run only**, via environment rather than by editing `.env` — verified working: `DB_NAME`, `PORT` and `CORS_ORIGINS` all override the `.env` file, and Vite picks up `PORT` and `VITE_*` from the process environment too. Use separate ports so a run never collides with the dev stack a human is using (the same isolation the freight/passenger suites get from their own databases): | | dev | e2e | |---|---|---| | hr-api | 3005 | **3105** | | finance-api | 3004 | **3104** | | hr-web | 5185 | **5285** | | finance-web | 5186 | **5286** | `CORS_ORIGINS` must be set on each API to its e2e web origin, or login dies at the preflight with a "Network Error" that says nothing about CORS. **Never point the running dev instances at the e2e database by accident** — a stray `nest start --watch` found bound to `smart_office_e2e` cost an hour of misdiagnosis, because every write appeared to succeed and then could not be found. ### 1.4 Teardown and hermeticity Journals, payroll runs and posted ledger entries are deliberately append-only by product design, so "run twice, DB unchanged" cannot mean "delete what got posted". The realistic story: - Fixtures live under the Active org, never touching the 2,415 real employees' data. - Each spec drives its fixture to a terminal, assertable state, or leaves a **named, findable row** (`E2E-` prefix) rather than deleting — matching the passenger suite's convention of asserting against real rows, not a wiped table. - The reset mechanism is periodically re-cloning `smart_office_e2e` from `smart_office_prod`, not a per-run DELETE. Cadence to be decided. --- ## 2. Route × permission × audience inventory Read from `App.tsx` in both apps, cross-referenced against each app's permission-role matrix (`hr-permissions.registry.ts`, 5 roles; `finance-permissions.registry.ts`, 4 roles). `viewOwn`-gated rows are additionally reachable by everyone via `SELF_SERVICE_KEYS` (HR) / `READ_ONLY_KEYS` (Finance). ### edr-hr-web (26 routes) | Path | Gate | Reachable by | |---|---|---| | `/`, `/me` | none | any authenticated employee | | `/employees` | `view:employee_profile` | hr_manager, hr_officer, payroll_admin, recruitment_officer | | `/employees/new` | `create:employee_profile` | hr_manager, hr_officer | | `/employees/:id` | `view:employee_profile` | as `/employees` | | `/employees/:id/edit` | `update:employee_profile` | hr_manager, hr_officer | | `/organisation`, `/departments`, `/job-titles`, `/job-positions` | `view:hr_org_structure` | hr_manager, hr_officer, payroll_admin, recruitment_officer | | `/reports` | `view:hr_report` | hr_manager, hr_officer, payroll_admin | | `/appraisals` | `submit_self:appraisal` | everyone (self-service) | | `/appraisals/reviews` | `submit_manager:appraisal` | hr_manager | | `/appraisals/cycles` | `manage:appraisal_cycle` | hr_manager | | `/recruitment`, `/recruitment/:id` | `view:application` | hr_manager, recruitment_officer | | `/payroll` | `view_all:payslip` | hr_manager, payroll_admin | | `/payroll/settings` | `manage:salary_rule` | hr_manager, payroll_admin | | `/payslips` | `view_own:payslip` | everyone | | `/attendance` | `view_own:attendance` | everyone | | `/attendance/approvals` | `approve:regularization` — confirmed NOT the same trap as leave; both roles hold this key | hr_manager, hr_officer | | `/attendance/register` | `view_all:attendance` | hr_manager, hr_officer | | `/attendance/settings` | `view_own:attendance` (read; write gated in-page on `manage:work_schedule`) | everyone (read) | | `/leave` | `view_own:leave_request` | everyone | | `/leave/approvals` | **fixed 2026-08-24** — was `approve_l1` solo (ungrantable), now `[approve_l1, approve_l2]` | any line manager (dynamic) + hr_manager (static L2) | | `/leave/register` | `view_all:leave_request` | hr_manager, hr_officer | | `/leave/settings`, `/leave/types`, `/leave/holidays` | `view_own:leave_request` (read) | everyone (read) | | `/forbidden` | none | anyone denied elsewhere | ### finance-web (14 routes) | Path | Gate | Reachable by | |---|---|---| | `/`, `/forbidden` | none | any authenticated staff | | `/accounts` | `view:gl_account` | all 4 roles | | `/journals`, `/journals/:id` | `view:journal_entry` | all 4 roles | | `/journals/new` | `create:journal_entry` | finance_manager, accountant | | `/periods` | `view:fiscal_period` | all 4 roles | | `/receivables`, `/revenue-mappings` | `view:receivable` | all 4 roles | | `/payables`, `/payroll` | `view:payable` | all 4 roles | | `/budgets`, `/cost-centers` | `view:budget` | finance_manager, accountant, finance_auditor (**not** cashier) | | `/assets` | `view:fixed_asset` | all 4 roles | | `/reports` | `view:finance_report` | all 4 roles | | `/cutover` | `view:fiscal_period` (reuses `period.view`, not a new key) | all 4 read; only finance_manager can act | Every write action is gated **in-page**, not at the route level — `finance_auditor` and `cashier` can open most screens but most buttons on them should be disabled or absent. That belongs in the scenario matrix (§5) and needs its own `data-testid`s (§4). ### Verified persona boundaries Driven against the running services rather than inferred from the matrix: | persona | own-leave | approvals | employees | payroll | recruitment | |---|---|---|---|---|---| | hr-employee | 200 | 403 | 403 | 403 | 403 | | hr-manager | 200 | 200 | 200 | 200 | 200 | | hr-payroll-admin | 200 | 403 | 200 | 200 | 403 | | hr-recruitment-officer | 200 | 403 | 200 | 403 | 200 | | persona | accounts | journals | create-journal | budgets | cutover | |---|---|---|---|---|---| | finance-manager | 200 | 200 | past guard | 200 | 200 | | finance-accountant | 200 | 200 | past guard | 200 | 200 | | finance-cashier | 200 | 200 | **403** | **403** | 200 | | finance-auditor | 200 | 200 | **403** | 200 | 200 | ("past guard" = 400 from DTO validation on a deliberately empty body, i.e. the permission check was passed.) --- ## 3. Network contract per flow Paths come from the controllers (`@Controller()` base + verb literals). `(verify)` means the path is confirmed but the guard should be re-read at test-writing time. ### HR | Flow | Method + path | Key fields | Notes | |---|---|---|---| | Leave — quote | `GET /leave-requests/quote` | day count, balance after | drives the request-modal preview | | Leave — submit | `POST /leave-requests` | `id`, `status: SUBMITTED`, `chargedDays` | day count frozen at submission | | Leave — awaiting-me | `GET /leave-requests/awaiting-me` | `{items, total}` | **gate fixed**: `[approve_l1, approve_l2]` | | Leave — approve/reject | `PATCH /leave-requests/:id/approve`, `/:id/reject` | `status`, `decidedByEmployeeId` | deducts in the same transaction — assert response AND `hr.leave_ledger_entries` | | Leave — cancel | `PATCH /leave-requests/:id/cancel` | reversal entry | REVERSAL row, capped at one per entry by a unique index | | Leave balance | via `/leave-balances` | balance = `SUM(days)` | **never stored** — the DB assertion must SUM the ledger | | Attendance — clock | `POST /attendance/check-in`, `/check-out` | `work_date` (DATE) | night shift stays on the day it began | | Attendance — regularize | `POST /attendance-regularizations` → `PATCH /:id/approve` | prior values preserved | applies **on approval** | | Overtime | `GET /overtime/rates` + request/approve `(verify)` | category, multiplier | category derived, multiplier **frozen on the claim** | | Payroll — lifecycle | `POST /payroll-runs` → `/:id/calculate` → `PATCH /:id/approve` → `/:id/mark-paid` | `status` | recalculation refused after APPROVED; one run per period | | Payroll — payslips | `GET /payroll-runs/my-payslips`, `/payslips/:id` | own + approved only | | | Appraisal — cycle open | `POST /appraisal/cycles/:id/open` | criteria copied | copy independent of later template edits | | Appraisal — score | `POST /appraisal/:id/manager` | `finalScore` | manager's score only, never averaged | | Recruitment — hire | `POST /recruitment/offers/:id/hire` | IAM user + employee + profile + salary | re-hiring the same offer → 409 | | Reports — leave liability | `GET /reports/leave-liability` | valued days | only types with `max_carry_over_days > 0` | | Org — hire | `POST /org/positions/:id/hire` | employee, `status='pending'` | `pending` is normal (2,424 of 2,433 real employees are) — only **org** status gates login | | Job positions | `GET /job-positions` | `{items, total}` | **fixed**: 500'd for any non-super-admin (`hr.job_positions` has no org column; must join `iam.positions`) | ### Finance | Flow | Method + path | Notes | |---|---|---| | GL — post | `POST /journals` → `POST /journals/:id/post` | balances or refused; refused outside a fiscal period; enforced in service **and** a DB constraint | | GL — reverse | `POST /journals/:id/reverse` | links both ways, requires a reason | | AR — ingest | broker consumer (`payment.#`), not HTTP | idempotent under redelivery; needs `PAYMENT_RABBITMQ_URL` (off by default) | | AP — bills | `POST /payables/bills` → `/:id/approve` → `/:id/payments` | approve = `approve:supplier_bill` (finance_manager only); pay = `record:supplier_payment` (accountant + cashier) | | AP — payroll→GL | `POST /payables/payroll/runs/:id/post` | depends on an HR payroll run existing first | | Budgeting | `POST /budgeting/budgets/:id/approve` | budget vs actual vs committed | | Assets — depreciation | `POST /assets/depreciation/run` | counts from `depreciation_entries` **plus** `opening_periods_charged`, in both the run and the schedule | | Assets — dispose | `POST /assets/:id/dispose` | opening figures may not combine with `fundingAccountId` | | Reports | `GET /reports/trial-balance`, `/balance-sheet` | debits = credits; assets = liabilities + equity + unclosed result | | Cutover — readiness | `GET /cutover/readiness` | 5 checks; **suspense check counts posted lines only** | | Cutover — import | `POST /cutover/opening-balances` | DRAFT entry; 3900 plug **computed, never accepted from the caller** | | Cutover — set date | `PUT /cutover` | a payment dated before cutover must be **SKIPPED**, recorded in `inbound_events` | --- ## 4. `data-testid` prerequisite checklist **Landed** (this pass). The inventory below was corrected against the real components while wiring it in — several entries in the original draft named the wrong file or an element that doesn't exist in the shape assumed; those are noted inline. `SpotlightSearchProps` still rejects `data-testid` — the palette input is addressed by placeholder, not a testid. **HR (`apps/edr-hr-web/src/features/**`)** - `leave/components/RequestLeaveModal.tsx` (not `leave/RequestModal.tsx` — that file doesn't exist): `leave-request-type`, `leave-request-start-date`, `leave-request-end-date` (the pair sits inside a `leave-request-dates` group), `leave-request-submit`. Required a `data-testid` pass-through prop added to the shared `EthiopianDateInput` component, which didn't forward one. - `leave/LeaveApprovalsPage.tsx`: `leave-approval-row-`, `leave-approve-btn-`, `leave-reject-btn-` - `attendance/components/ClockWidget.tsx`: `attendance-clock-in` / `attendance-clock-out` — one button, testid set conditionally on state, not two coexisting elements. - `attendance/AttendanceApprovalsPage.tsx`: `attendance-regularization-row-`, `attendance-regularization-approve-btn-`, `attendance-regularization-reject-btn-` (the shared `Actions` component also renders the overtime tab's rows, so these testids appear there too — harmless, just note it if a spec ever needs to disambiguate). - `attendance/MyAttendancePage.tsx` (a **second**, distinct "regularization" UI — the employee's own history, not the approval queue above): `attendance-record-row-`, `attendance-my-regularization-row-`. - `payroll/PayrollRunsPage.tsx`: `payroll-run-row-`, and — corrected from the original bare names, since these render once per row — `payroll-calculate-btn-`, `payroll-approve-btn-`, `payroll-mark-paid-btn-`. - `appraisal/components/ScoringForm.tsx` (shared by both self- and manager-review — required adding a new `submitTestId?` prop, threaded from each caller): `appraisal-criterion-` (keyed by the criterion's business `code`, not its DB id), `appraisal-weight-total`, `appraisal-submit-self`/`appraisal-submit-manager` (set via the new prop in `MyAppraisalsPage.tsx`/`AppraisalReviewsPage.tsx` respectively). - `recruitment/components/ApplicationDrawer.tsx`: `recruitment-hire-btn`, `recruitment-hire-confirm-btn`. **`recruitment-stage-select` does not exist** — there is no dropdown; stage advance is a row of per-stage buttons in `recruitment/OpeningDetailPage.tsx`, testid `recruitment-stage-advance-btn-`. - `reports/ReportsPage.tsx`: `report-export-btn-` (the shared `exportButton(name, rows)` helper takes the testid once and every instantiation gets it automatically — 7 reports, not 6), plus a per-report row testid keyed by each table's natural key, e.g. `report-headcount-row-`, `report-leave-liability-row--`. **Finance (`apps/finance-web/src/features/**`)** - `journals/NewJournalPage.tsx`: `journal-line-row-` (a client-generated draft key, not a DB id — these rows have no id yet), `journal-balance-indicator` (on both the balanced and out-of-balance branches). **`journal-post-btn` is NOT on this page** — this page only ever creates a DRAFT. It's on `journals/JournalDetailPage.tsx` instead (not in the original checklist at all). - `payables/PayablesPage.tsx`: `bill-row-`, `bill-approve-btn` (singular — only one bill is open at a time via the drawer, no `-` needed), `bill-record-payment-btn`, `bill-record-payment-confirm-btn` (on the payment modal's actual submit button). - `budgeting/BudgetsPage.tsx`: **`budget-row-` does not apply** — budgets are chosen via a `