23 KiB
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_e2efromsmart_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-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-submitleave/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-btnappraisal/*:appraisal-criterion-<key>,appraisal-weight-total,appraisal-submit-self,appraisal-submit-managerrecruitment/ApplicationDrawer.tsx:recruitment-stage-select,recruitment-hire-btnreports/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-btnpayables/PayablesPage.tsx:bill-row-<id>,bill-approve-btn-<id>,bill-record-payment-btn-<id>budgeting/BudgetsPage.tsx:budget-row-<id>,budget-approve-btnassets/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-previewreports/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) againstsmart_office_e2e.storageStateper 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_e2ehas over the passenger suite's 5544 database, and the reason Phase 0 did not recommend a fresh Prisma-seeded DB. New rows get anE2E-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.
/leave/approvalswas unreachable by any role — gated oncan:approve_l1:leave_request, a key the seed deliberately grants to no role, position, or position-type (zero rows in bothiam.position_permissionsandiam.position_type_permissions, system-wide). Fixed in the nav gate, route gate, badge hook, and the three backend routes to acceptapprove_l1orapprove_l2— matching whatLeaveRequestsService.assertCanDecidealready assumed. Verified: 403 → 200 for an L2-only persona.GET /job-positions500'd for any non-super-admin —findPagereferencedposition.organization_id, a columnhr.job_positionshas never had (it deliberately stores no copy of IAM's org/unit). Fixed by joiningiam.positionsthe way the file's ownheadcountTotalsalready did correctly. Verified: 500 → 200.
HR-03 and HR-10 exist to pin these.
Open items before Phase 1 writes specs
- Land the
data-testidpass (§4), one reviewable commit per app. - Decide the re-clone cadence for
smart_office_e2e(§1.4). - Separately, not an e2e concern:
iam.organizationsholds 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.