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

23 KiB
Raw Blame History

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:

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-testids (§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-regularizationsPATCH /: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/calculatePATCH /: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 /journalsPOST /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-adminfindPage 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.