Files
edr-platform/docs/hr-finance-ui-e2e-matrix.md
2026-08-25 00:11:39 +03:00

356 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
**Current state:** only nav-level testids exist (`nav-item-*`, `nav-group-*` from the redesign).
**Zero on any feature page.** This is the blocker that stalled the passenger effort; treat it as a
blocker, not a footnote. Note `SpotlightSearchProps` rejects `data-testid` — address the palette
input by placeholder.
**HR (`apps/edr-hr-web/src/features/**`)**
- `leave/RequestModal.tsx`: `leave-request-type`, `leave-request-dates`, `leave-request-submit`
- `leave/LeaveApprovalsPage.tsx`: `leave-approval-row-<id>`, `leave-approve-btn-<id>`, `leave-reject-btn-<id>`
- `attendance/*`: `attendance-clock-in`, `attendance-clock-out`, `attendance-regularization-row-<id>`
- `payroll/PayrollRunsPage.tsx`: `payroll-run-row-<id>`, `payroll-calculate-btn`, `payroll-approve-btn`, `payroll-mark-paid-btn`
- `appraisal/*`: `appraisal-criterion-<key>`, `appraisal-weight-total`, `appraisal-submit-self`, `appraisal-submit-manager`
- `recruitment/ApplicationDrawer.tsx`: `recruitment-stage-select`, `recruitment-hire-btn`
- `reports/ReportsPage.tsx`: `report-row-<key>`, `report-export-btn`
**Finance (`apps/finance-web/src/features/**`)**
- `journals/NewJournalPage.tsx`: `journal-line-row-<n>`, `journal-balance-indicator`, `journal-post-btn`
- `payables/PayablesPage.tsx`: `bill-row-<id>`, `bill-approve-btn-<id>`, `bill-record-payment-btn-<id>`
- `budgeting/BudgetsPage.tsx`: `budget-row-<id>`, `budget-approve-btn`
- `assets/AssetsPage.tsx`: `asset-row-<id>`, `run-depreciation-btn`, `dispose-btn-<id>`
- `cutover/CutoverPage.tsx`: `cutover-readiness-check-<key>`, `cutover-set-date-btn`, `cutover-import-paste`, `cutover-import-preview`
- `reports/ReportsPage.tsx`: `report-trial-balance-row-<code>`, `report-balance-sheet-total`
A starting inventory from reading route components, not exhaustive — whoever writes each spec adds
what their scenario needs in the same pass.
---
## 5. Scenario matrix
**DOM** = UI assertion, **NET** = network, **DB** = database.
| id | module | audience | proves | DOM | NET | DB | fixture |
|---|---|---|---|---|---|---|---|
| HR-01 | leave | employee | submitting freezes the day count | request in "My leave" | `POST /leave-requests` 201, `chargedDays` matches quote | `charged_days` unchanged after a later holiday-calendar edit | employee with an entitlement |
| HR-02 | leave | line manager | L1 approval deducts atomically | balance updates | `PATCH /:id/approve` 200 | one DEDUCTION row in the same statement window as the status flip | the real manager/report position pair |
| HR-03 | leave | hr_manager | **L2 reaches the page and can decide** (regression pin) | `/leave/approvals` renders, not `/forbidden` | `GET /awaiting-me` 200 not 403 | — | hr-manager persona |
| HR-04 | leave | employee | cancel reverses, not deletes | both entries in ledger drawer | `PATCH /:id/cancel` 200 | REVERSAL row added; original untouched; unique index holds | an approved request |
| HR-05 | attendance | employee | night shift stays on its start date | one day marked | check-in 22:00, check-out after midnight | `work_date` = check-in date | — |
| HR-06 | payroll | payroll_admin | recalculation refused after approval | error on retry | `POST /:id/calculate` → non-2xx | status unchanged | an APPROVED run |
| HR-07 | payroll | employee | self-service is scoped | own, approved-only | `GET /my-payslips` | — | two employees, one run |
| HR-08 | appraisal | hr_manager | manager score is final | shown = manager's, not an average | `POST /:id/manager` | `final_score` = manager's raw score | template weights = 100 |
| HR-09 | recruitment | recruitment_officer | re-hiring an offer is refused | error on 2nd click | 2nd `POST /offers/:id/hire` → 409 | FILLED count unchanged | one vacancy, one offer |
| HR-10 | job-positions | hr_manager | **org-filtered list no longer 500s** (regression pin) | table renders | `GET /job-positions` 200 | — | any non-super-admin |
| HR-11 | reports | hr_manager | liability values only carry-over types | excludes sick/bereavement/paternity | `GET /reports/leave-liability` | cross-check filtered `SUM` | both balance kinds |
| HR-12 | gating | **hr-employee** | a gated screen is refused by direct URL | lands on `/forbidden` | 403 if forced | — | ✅ unblocked — persona verified 403 on 4 of 5 endpoints |
| FIN-01 | GL | accountant | unbalanced entry refused | error on submit | `POST /journals` mismatched → non-2xx | no row written | — |
| FIN-02 | GL | finance_manager | posting outside a period refused | error on post | `POST /:id/post` → non-2xx | stays DRAFT | journal outside any open period |
| FIN-03 | AP | finance_manager vs cashier | separation of duties | approve visible only to manager; pay only to accountant/cashier | matching 403s if forced | — | one bill, two personas |
| FIN-04 | assets | accountant | a migrated asset still depreciates | non-zero charge in schedule | `POST /assets/depreciation/run` | entry row added; `periodsCharged` past the opening count | asset with opening figures |
| FIN-05 | cutover | finance_manager | suspense counts posted lines only | panel matches DB state | `GET /cutover/readiness` | DRAFT-only batch reads PENDING, not FAIL | unposted opening import |
| FIN-06 | cutover | finance_manager | pre-cutover payment skipped, not double-posted | — | broker message before cutover → no journal | `inbound_events` SKIPPED; ledger unchanged | needs `PAYMENT_RABBITMQ_URL` |
| FIN-07 | reports | finance_auditor | trial balance balances | totals equal | `GET /reports/trial-balance` | `SUM(debit) = SUM(credit)` | any posted period |
| FIN-08 | gating | **cashier** | a manage-only screen is refused | — | 403 | — | ✅ unblocked — cashier verified 403 on create-journal and budgets |
---
## 6. Harness plan
- `e2e-hr-finance/playwright.config.ts` — one project per audience (8 personas + super admin).
- `webServer`: boot-or-reuse both API+web pairs on the e2e ports (§1.3) against `smart_office_e2e`.
- **`storageState` per role**: mint programmatically per role (`POST /api/v1/auth/login`) for
speed, plus **one persona through the real login UI** — the passenger suite's precedent — so the
actual `<LoginPage>` form stays covered.
- **Fixtures**: draw on the Active org's existing hierarchy rather than building an IAM tree from
nothing. This is the advantage `smart_office_e2e` has over the passenger suite's 5544 database,
and the reason Phase 0 did not recommend a fresh Prisma-seeded DB. New rows get an `E2E-` prefix.
- **Serialization**: serialize specs sharing a payroll run or journal period; parallelize
independent reads.
- **Run script**: `e2e-hr-finance/run.sh` — infra → build → seed (module permissions + personas) →
boot → mint storageStates → run → open report.
- **CI**: no pipeline exists for either app (no `Jenkinsfile.HR`/`Jenkinsfile.Finance`). Out of
scope for Phase 0; flagged rather than invented.
---
## 7. Bugs found while preparing this
The `hr.demo` recreation doubled as the first real non-super-admin browser pass either app has had.
Both were invisible to every prior pass because those ran as super admin, which bypasses the checks
involved.
1. **`/leave/approvals` was unreachable by any role** — gated on `can:approve_l1:leave_request`, a
key the seed deliberately grants to no role, position, or position-type (zero rows in both
`iam.position_permissions` and `iam.position_type_permissions`, system-wide). Fixed in the nav
gate, route gate, badge hook, and the three backend routes to accept `approve_l1` **or**
`approve_l2` — matching what `LeaveRequestsService.assertCanDecide` already assumed. Verified:
403 → 200 for an L2-only persona.
2. **`GET /job-positions` 500'd for any non-super-admin** — `findPage` referenced
`position.organization_id`, a column `hr.job_positions` has never had (it deliberately stores no
copy of IAM's org/unit). Fixed by joining `iam.positions` the way the file's own
`headcountTotals` already did correctly. Verified: 500 → 200.
HR-03 and HR-10 exist to pin these.
---
## Open items before Phase 1 writes specs
1. Land the `data-testid` pass (§4), one reviewable commit per app.
2. Decide the re-clone cadence for `smart_office_e2e` (§1.4).
3. **Separately, not an e2e concern**: `iam.organizations` holds rows named after applications
("EDR Freight", "EDR") rather than legal entities, alongside 10 Addis Ababa sub-cities from the
Smart Office lineage. ~10 employees between them, all Debarred. A cleanup decision for whoever
owns the tenant list — flagged, not acted on.