mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 12:00:59 +00:00
Muluhabt ERP modules
This commit is contained in:
@@ -165,7 +165,7 @@ Any inequality is a finding. The explorers show this chain is **broken by design
|
||||
- **Browser tests (Playwright):** greenfield — add runner + config. Portal has no server-side auth gate; backoffice needs `auth_token` cookie + localStorage seeded.
|
||||
- **Payments:** WALLET is fully offline-testable. Gateway flows driven by POSTing directly to `/webhooks/<provider>` on payment-api (Telebirr/CBE/eBirr have loose signature gating; Card/Waafi need valid HMAC). `SERVICE_AUTH_TOKEN` unset in dev = internal endpoints unguarded.
|
||||
- **Seed:** re-enable `prisma/seed.ts` steps or invoke seeder fns from a test bootstrap. Needs stations, routes+stops (distanceKm), schedules, seat classes, fare rules, FX rates, promos.
|
||||
- **DB:** ⚠️ doc drift — CLAUDE.md says `postgres-passenger:5434/edr_passenger`; actual `.env.example` says `localhost:5432/edr_database?schema=passenger`; no compose file provisions it. **Need target confirmed.**
|
||||
- **DB:** resolved — the platform is ONE database, schema-separated. Target is `localhost:5432/edr_database?schema=passenger` (as `.env.example` already said); the stale `postgres-passenger:5434/edr_passenger` claim is gone from CLAUDE.md. `infrastructure/docker/docker-compose.db.dev.yml` provisions it for dev; the e2e harness keeps its own hermetic Postgres on 5544.
|
||||
|
||||
## Open decisions (blocking Phase 2)
|
||||
|
||||
|
||||
161
docs/hr-finance-nav-redesign-report.md
Normal file
161
docs/hr-finance-nav-redesign-report.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# HR + Finance shell redesign — report (reconstructed 2026-08-25)
|
||||
|
||||
> Also published as an artifact:
|
||||
> https://claude.ai/code/artifact/5e0b90d8-e9dd-462b-819a-6aa19b83fc50
|
||||
|
||||
> **Why this file says "reconstructed".** The original report was destroyed with the rest of
|
||||
> the working tree on 2026-08-24 and was never committed to git. The restore the owner ran
|
||||
> came from a **2026-08-22** backup, which predates the entire redesign. This document
|
||||
> describes the shell **as it stands in the tree today**, re-verified in a browser on
|
||||
> 2026-08-25 — not as it was remembered. Where the current state differs from the record of
|
||||
> the original, the difference is called out rather than smoothed over.
|
||||
|
||||
## What was recovered vs. rebuilt
|
||||
|
||||
Recovery used two sources that the wipe did not touch, in preference to rewriting anything:
|
||||
|
||||
| Source | What it gave back |
|
||||
| --- | --- |
|
||||
| `~/.claude/file-history/<session>/` (pre-edit snapshots) | HR `nav-model.ts` (400 lines), `useApprovalCounts.ts`, HR `App.tsx`, `permissions.ts`, `AuthContext.tsx`, `RequireAuth.tsx`, both API bug fixes, the e2e Phase 0 matrix, the security write-up, the scoped seeder |
|
||||
| The session transcript `74e086a2….jsonl` (Write/Edit replay) | The whole `e2e-hr-finance/` Playwright suite — 8 files, replayed with no failed or ambiguous edits |
|
||||
|
||||
Everything above is **byte-for-byte the original**, not a re-derivation.
|
||||
|
||||
The following had no snapshot — they were last written on 2026-08-23, in a session whose
|
||||
transcript is absent — and were **rebuilt from their contracts** (the recovered `nav-model.ts`
|
||||
specifies the exact `SidebarItem` shape the sidebar must support, which made this far less
|
||||
speculative than it sounds):
|
||||
|
||||
- `packages/ui-common/…/Sidebar.tsx` and `DashboardLayout.tsx`
|
||||
- both apps' `AppShell.tsx` and `CommandPalette.tsx`
|
||||
- `finance-web`'s `nav-model.ts`
|
||||
- the `@custom-variant dark` line in both apps' `index.css`
|
||||
- the HR dashboard's "Needs you today" panel and the Finance overview tiles
|
||||
|
||||
**Not recovered, and not invented:** `docs/prompts/hr-finance-ui-redesign-prompt.md`,
|
||||
`docs/prompts/hr-finance-e2e-prompt.md`, and the original `hr-finance-nav-redesign-plan.md`.
|
||||
These were inputs, not outputs; writing plausible replacements would misrepresent what drove
|
||||
the work. They are simply gone.
|
||||
|
||||
## The shell as it now stands
|
||||
|
||||
**Grouping.** Both apps group subject-first with a personal "My work" group on top. The test
|
||||
is "does this screen show only my own record?", mirroring `viewOwn` vs `viewAll`.
|
||||
|
||||
- HR: 25 screens → **9 top-level rows** — Overview, My work, Approvals, People, Time & leave,
|
||||
Payroll, Talent, Reports, Settings.
|
||||
- Finance: 13 screens → **8 top-level rows** — Overview, Ledger, Revenue, Spend, Planning,
|
||||
Fixed assets, Reports, Setup.
|
||||
|
||||
Nav gates mirror route gates. A group with no permitted child is not rendered at all, and a
|
||||
group holding exactly one permitted screen collapses to a flat row keeping the group's icon —
|
||||
confirmed live: `hr_manager` sees Payroll as a single "Payroll runs" row, not a group.
|
||||
|
||||
**Group headers carry no `href`.** They render as `<button>`, so a header can never navigate
|
||||
into something the reader is gated out of.
|
||||
|
||||
**Command palette.** Cmd/Ctrl-K, built from `visibleEntries` — the same gated source the
|
||||
sidebar renders — so the palette cannot offer a screen the sidebar hides. A visible "Search
|
||||
⌘K" pill sits beside the breadcrumb, because nobody discovers a shortcut that is never shown.
|
||||
HR: 27 actions (25 screens + profile + logout). Finance: 14 (13 screens + logout).
|
||||
|
||||
**Responsive rail** behind an opt-in `responsive` prop, default off, so the ~94 other
|
||||
`@edr/ui-common` consumers are untouched: ≥1280 the full 256px column, 900–1279 a 64px icon
|
||||
rail whose groups open a flyout, <900 hidden behind a hamburger opening an overlay drawer that
|
||||
closes on navigate, Escape, backdrop, and on widening. All five behaviours verified.
|
||||
|
||||
## Defects found and fixed
|
||||
|
||||
Nine were recorded against the original seven slices. The ones that live in code today, and
|
||||
are verified in the current tree:
|
||||
|
||||
1. **`<aside>` had no `overflow-y-auto`** and sat in `min-h-screen`, so HR's 25 entries
|
||||
(~1,180px) pushed *Reports* and *My profile* below the fold and scrolled the whole page.
|
||||
Now `sticky top-0 h-screen overflow-y-auto`. Finance's 13 entries fit, which is why the
|
||||
Finance-only browser pass never saw it.
|
||||
2. **Active-row matching was per-item prefix matching**, so `/leave/approvals` highlighted
|
||||
*both* "My leave" and "Leave approvals". Now longest-match-wins over the whole tree, with
|
||||
whole-segment boundaries. Verified: exactly one `aria-current="page"` on all 39 routes.
|
||||
3. **`min-w-0` on the content column.** A flex child will not shrink below its content's
|
||||
intrinsic width, so one wide table stretched the column and scrolled the *page* sideways,
|
||||
header included. Verified: 0 horizontal overflow at 1440 / 1100 / 820 / 390.
|
||||
4. **Every `dark:` utility in the shell was dead.** The shell toggles a `dark` *class*;
|
||||
Tailwind v4 defaults `dark:` to the OS preference. `ui-common` declares
|
||||
`@custom-variant dark (&:where(.dark, .dark *))` in its own stylesheet, but each app runs a
|
||||
*second* Tailwind build over ui-common's source via `@source`, and the app's sheet loads
|
||||
later — so the app's plain `text-slate-800` beat ui-common's class-scoped
|
||||
`dark:text-slate-100`. Fixed by declaring the same variant in each app's `index.css`.
|
||||
Measured today: wordmark **14.08:1 light, 16.36:1 dark** in both apps.
|
||||
**Any new Mantine + Tailwind app in this repo needs that line.**
|
||||
5. **Persisted group expansion did not survive a reload**, even though it was stored
|
||||
correctly: the initial state spread the auto-expand map *whole*, and that map says `false`
|
||||
for every group not holding the current page, overwriting what the reader left open. Only
|
||||
its `true` entries may be merged. Generalise: *an "open the active section" map is not a
|
||||
statement about the other sections.* Verified: a group opened by hand is still open after
|
||||
reload, and still open after navigating into a different section.
|
||||
6. **Dead header controls.** The globe now drives the `setLanguage()` that already existed
|
||||
unreachable in `src/i18n/index.ts` (en / አማርኛ, persists across reload — verified). The bell
|
||||
is off in both apps: there is no notification source, and a never-clearing red dot is a lie.
|
||||
HR's Profile goes to `/me`; Finance hides it, having no profile screen.
|
||||
7. **The app title rendered twice per screen.** The top bar now carries a breadcrumb
|
||||
(`Group / Screen`) instead. Verified present and correct on all 39 routes.
|
||||
8. **Two API/permission bugs** (recovered from snapshots, re-verified live as `hr.demo`, a
|
||||
plain `hr_manager`):
|
||||
- `/leave/approvals` was unreachable by anyone but a super admin — nav gate, route gate and
|
||||
badge hook all required `approve_l1:leave_request` alone, a permission the seed grants to
|
||||
no role, position, or position-type. Widened to `[approve_l1, approve_l2]`; `NavEntry.gate`
|
||||
and `can()` are typed `string | string[]` with OR semantics. **Now lands on the page.**
|
||||
- `GET /job-positions` 500'd for any non-super-admin: `JobPositionsRepository.findPage`
|
||||
referenced `position.organization_id`, a column `hr.job_positions` has never had. Fixed by
|
||||
joining `iam.positions` via `EXISTS`. **Now 200, no 5xx.**
|
||||
|
||||
### One defect found during this reconstruction
|
||||
|
||||
**The command palette searched group names.** Mantine's default Spotlight filter matches on
|
||||
`description` as well as `label`, and the group is carried in `description` — so typing "lea"
|
||||
also returned *Attendance register* (via "Time & leave"), pushing the wanted screen down the
|
||||
list. The original recorded this as an open trade-off and later as resolved; the resolution
|
||||
did not survive, so it was fixed here with an explicit `filter` that matches the screen name
|
||||
only. The group remains visible as context. Verified: `"lea"` returns 5 leave screens and does
|
||||
**not** match *Attendance register*.
|
||||
|
||||
## Verification actually performed (2026-08-25)
|
||||
|
||||
Type-checks: `@edr/ui-common`, `@edr/hr-web`, `@edr/finance-web` all pass.
|
||||
Regression control for the ~94 other consumers: `@edr/freight-backoffice` type-checks to
|
||||
**exactly 956 errors**, the same count recorded before the redesign, and **none mention
|
||||
`Sidebar` or `DashboardLayout`**.
|
||||
|
||||
Driven in a real browser against the live stack (hr-api :3005, finance-api :3004,
|
||||
passenger-api :4000, hr-web :5185, finance-web :5186) on `smart_office_prod`:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Routes walked, both apps, both themes | 25 HR + 14 Finance × light/dark = **78 route-visits** |
|
||||
| Page errors | **0** |
|
||||
| Exactly one active nav row | **39/39 routes** |
|
||||
| Breadcrumb present and correct | **39/39 routes** |
|
||||
| Horizontal overflow @ 1440/1100/820/390 | **0** |
|
||||
| Palette, language, theme, rail, flyout, drawer, persistence | all pass, **0 page errors** |
|
||||
| Non-super-admin pass as `hr.demo` (`hr_manager`) | 9 nav rows, both regressions clear, **0 5xx** |
|
||||
|
||||
**Failing requests seen, and why they are not shell defects:** a `401 GET /api/v1/me` on the
|
||||
login page (the pre-auth session probe, expected), and `404`s on
|
||||
`/api/v1/employee-profiles/me` and `/api/v1/leave-balances/me` — those accounts have no HR
|
||||
profile or leave-balance rows. They are data-level and appear identically for both accounts.
|
||||
|
||||
**Not done:** no before/after image pair exists — nothing was screenshotted before the
|
||||
redesign, and the pre-redesign state no longer exists to photograph. The "before" survives
|
||||
only as the measurements quoted above.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- `docs/prompts/` is gone (see above). If those prompts matter, they need to be rewritten from
|
||||
intent, by a human.
|
||||
- Finance's palette has 14 actions where the original record says 15. The current count is
|
||||
internally consistent (13 screens + logout; Finance has no profile screen), so the
|
||||
discrepancy is most likely one extra action in the original that is not reconstructible.
|
||||
- The e2e Phase 0 matrix (`docs/hr-finance-ui-e2e-matrix.md`) and the `e2e-hr-finance/` suite
|
||||
are both restored, but the suite has **not** been executed in this pass.
|
||||
- **None of this is committed.** The tree has been lost once already with this work
|
||||
uncommitted; committing it is the single highest-value next action.
|
||||
355
docs/hr-finance-ui-e2e-matrix.md
Normal file
355
docs/hr-finance-ui-e2e-matrix.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# 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.
|
||||
397
docs/integration-map.md
Normal file
397
docs/integration-map.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# Integration map — HR, Finance and the operational systems
|
||||
|
||||
> Section 5 of the ERP expansion. Written 2026-08-22, after HR (3.1–3.7) and
|
||||
> Finance (4.1–4.6) were built and verified.
|
||||
>
|
||||
> This describes how six services share one database without owning each other's
|
||||
> data. If something here contradicts the code, the code is the truth and this
|
||||
> file is a bug — fix it in the same PR.
|
||||
|
||||
---
|
||||
|
||||
## 1. The services
|
||||
|
||||
| Service | Owns schema | Stack | Port |
|
||||
|---|---|---|---|
|
||||
| `edr-freight-api` | `freight` **and `iam`** | NestJS + TypeORM | 3001 |
|
||||
| `edr-passenger-api` | `passenger` | NestJS + Prisma | 4000 |
|
||||
| `edr-payment-api` | `edr_payment` | NestJS + TypeORM | 3003 |
|
||||
| `edr-hr-api` | `hr` | NestJS + TypeORM | 3005 |
|
||||
| `finance-api` | `finance` | NestJS + TypeORM | 3004 |
|
||||
| `edr-gps-tracker` | — | separate service | — |
|
||||
|
||||
**One Postgres database, separated by schema.** Not one database per service.
|
||||
Everything below follows from that: the isolation is a discipline, not an
|
||||
infrastructure boundary, so it has to be enforced by rules people can read.
|
||||
|
||||
`edr-freight-api` is the authoritative owner of `iam` — it ships the
|
||||
`iam:migration:*` scripts. No other service migrates that schema.
|
||||
|
||||
---
|
||||
|
||||
## 2. Who may write what
|
||||
|
||||
This is the load-bearing table. Everything else is detail.
|
||||
|
||||
| Schema | Written by | Read by |
|
||||
|---|---|---|
|
||||
| `iam` | freight-api (migrations); **HR and Finance only through IAM's own services** | everyone |
|
||||
| `freight` | freight-api | freight-api, finance-api (read-only) |
|
||||
| `passenger` | passenger-api | passenger-api, finance-api (read-only) |
|
||||
| `edr_payment` | payment-api | payment-api, finance-api (read-only) |
|
||||
| `hr` | hr-api | hr-api, finance-api (read-only) |
|
||||
| `finance` | **finance-api only** | finance-api |
|
||||
|
||||
**Finance writes nothing but `finance`.** It is a reader of the whole platform
|
||||
and an owner of one schema. No source system writes `finance.*` either — there
|
||||
is no path by which freight could insert a journal entry, and that is deliberate:
|
||||
the ledger's invariants live in one service, and anything that bypassed it would
|
||||
bypass them too.
|
||||
|
||||
**HR writes `iam`, but never directly.** `IamOperationsService` resolves IAM's
|
||||
own services out of the container (`ModuleRef.get(..., {strict: false})`) and
|
||||
calls them, so IAM's validation, transactions and audit trail all still apply.
|
||||
`IamDirectoryService` is read projections only.
|
||||
|
||||
---
|
||||
|
||||
## 3. The three integration mechanisms
|
||||
|
||||
Only three. Anything that looks like a fourth is a mistake.
|
||||
|
||||
### 3.1 Embedded module (in-process, transactional)
|
||||
|
||||
`IamModule.forRoot({...})` is imported by freight-api, hr-api and finance-api.
|
||||
IAM is a library every service takes as a dependency, not a service they call.
|
||||
|
||||
This is why: **the hire flow creates an IAM user, an employee, an
|
||||
employee-position and an HR profile in ONE transaction.** Over HTTP it could
|
||||
not, and a partial failure would strand an IAM user with no HR profile.
|
||||
|
||||
Consequence, and it is intended: hr-api and finance-api both serve IAM's own
|
||||
routes, including `POST /api/v1/auth/login`. They are second front ends to IAM's
|
||||
capabilities, not forks of them.
|
||||
|
||||
Registration must use **globs over both package dists** with
|
||||
`autoLoadEntities: false` — see `apps/finance-api/src/config/database.config.ts`.
|
||||
A hand-maintained entity list goes stale on every package bump; the partial
|
||||
`forFeature` set plus `autoLoadEntities` is what throws
|
||||
`Entity metadata for User#sessions was not found` at boot.
|
||||
|
||||
### 3.2 Events (asynchronous, at-least-once)
|
||||
|
||||
One exchange: **`payment.events`**, a durable topic exchange, with
|
||||
`payment.events.dlx` behind it.
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ edr-payment-api │
|
||||
│ transactional │
|
||||
│ outbox │
|
||||
└────────┬─────────┘
|
||||
│ publish payment.<service>.<outcome>
|
||||
┌─────────▼──────────┐
|
||||
│ payment.events │ topic, durable
|
||||
└──┬───────┬──────┬──┘
|
||||
payment.freight.* payment.passenger.* payment.#
|
||||
┌────────▼──┐ ┌──▼──────────┐ ┌──▼──────────┐
|
||||
│ freight │ │ passenger │ │ finance │
|
||||
│ .payment- │ │ .payment- │ │ .payment- │
|
||||
│ events │ │ events │ │ events │
|
||||
└───────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
**Routing keys are three words**: `payment.passenger.succeeded`,
|
||||
`payment.freight.failed` — built as `payment.${service}.${outcome}`. AMQP's `*`
|
||||
matches exactly one word, so a `payment.*` binding matches **nothing**. Finance
|
||||
binds `payment.#` because a ledger should see every payment.
|
||||
|
||||
Finance declares its **own** queue (`finance.payment-events`). It must never
|
||||
share freight's or passenger's: a shared queue delivers each message to whichever
|
||||
consumer takes it first, and freight would start losing payments to the ledger.
|
||||
|
||||
**Delivery is at-least-once.** The publisher is a transactional outbox with
|
||||
retry and backoff; `eventId` is the outbox row id and is stable across every
|
||||
redelivery. Every consumer must be idempotent — see §5.
|
||||
|
||||
The envelope (`PaymentEvent` in `@edr/types`):
|
||||
|
||||
| Field | Note |
|
||||
|---|---|
|
||||
| `version`, `eventId`, `eventType`, `occurredAt` | `eventId` is the dedupe key |
|
||||
| `service`, `referenceType`, `referenceId` | which domain order |
|
||||
| `merchantOrderId`, `intentId`, `provider` | provider-side identity |
|
||||
| `amountMinor`, `currency` | **carries MAJOR units despite the name** — see §4 |
|
||||
| `paidAt` / `failureCode` | per event type |
|
||||
|
||||
### 3.3 Read-only cross-schema projection
|
||||
|
||||
Finance reads `freight`, `passenger`, `hr` and `edr_payment` with
|
||||
schema-qualified raw SQL. This is sanctioned — it is the same approach HR's 3.7
|
||||
reports use, and it is justified by the single-database topology.
|
||||
|
||||
It carries one obligation, from the platform's hard rules:
|
||||
|
||||
> **Validate every raw SQL statement against a real database before shipping it.**
|
||||
> A typo'd column name is a runtime 500 no type-checker will catch.
|
||||
|
||||
That rule earned its place twice during this build (§6).
|
||||
|
||||
Because upstream schemas deploy independently, Finance **probes for source
|
||||
availability** (`to_regclass`) and reports it at `GET /api/v1/revenue/sources`.
|
||||
The UI then says "this source is not present" instead of showing a zero. On the
|
||||
current dev replica, freight's billing tables and the whole `edr_payment` schema
|
||||
are absent — "no freight revenue" and "freight billing is not deployed" are
|
||||
completely different facts and must not look alike.
|
||||
|
||||
### 3.4 What is NOT an integration mechanism
|
||||
|
||||
`${source}.invoice.paid`, `booking.cancelled` and similar are **in-process
|
||||
`EventEmitter2`** events inside a single app. They do not cross a service
|
||||
boundary and cannot be subscribed to from another service, despite reading like
|
||||
broker events. Note also that freight emits `lastmile.*`/`firstmile.*` while
|
||||
several listeners subscribe to `last_mile.*`/`first_mile.*` — a pre-existing
|
||||
mismatch, not something Finance relies on.
|
||||
|
||||
---
|
||||
|
||||
## 4. The money contract
|
||||
|
||||
`apps/finance-api/src/common/money.ts` is the authority. Summary:
|
||||
|
||||
| Store | Representation | Unit |
|
||||
|---|---|---|
|
||||
| `freight.*` | `numeric(14,2)` | **major** |
|
||||
| `passenger.*` Prisma `Int` columns | integer | **minor (cents)** |
|
||||
| `passenger."PaymentIntent".amountMinor` | double | **major**, despite the name |
|
||||
| `edr_payment.payment_intent.amount_minor` | double | **major**, despite the name |
|
||||
| payment events `amountMinor` | JSON number | **major**, despite the name |
|
||||
| `hr.*` payroll, `finance.*` | `numeric(14,2)` | **major** |
|
||||
|
||||
**Never infer the unit from the name.** Every `*Minor` field on the payment path
|
||||
carries major units; the names are a wire contract across service APIs and are
|
||||
not worth a cross-service rename.
|
||||
|
||||
**Currencies are never summed together.** Confirmed passenger bookings are
|
||||
charged in ETB **and DJF and USD** — 151.4M / 6.18M / 133k on the dev replica.
|
||||
Adding them reports 157.7M and overstates ETB revenue by **6.32M**. Finance
|
||||
groups every revenue projection by currency, posts only ledger-currency amounts,
|
||||
and reports the rest as "needs a rate" rather than converting at a guess.
|
||||
|
||||
---
|
||||
|
||||
## 5. Idempotency — how each path avoids double-counting
|
||||
|
||||
Every automated write into the ledger is keyed, because at-least-once delivery
|
||||
and human re-clicks are both certain.
|
||||
|
||||
| Path | Key | Where enforced |
|
||||
|---|---|---|
|
||||
| Payment event → cash receipt | `eventId` | unique index on `finance.inbound_events.event_id`; claimed with `INSERT … ON CONFLICT DO NOTHING` |
|
||||
| Any automated journal | `(organization, source_module, source_id)` | partial unique index on `finance.journal_entries` |
|
||||
| Revenue recognition | `('<source>-revenue', 'YYYY-MM')` | same index |
|
||||
| Payroll → GL | `('hr-payroll', <run id>)` | same index |
|
||||
| Supplier bill approval | `('supplier-bill', <bill id>)` | same index |
|
||||
| Depreciation | `('depreciation', <period id>)` | same index + one run per period |
|
||||
| Statutory remittance | `(type, period)` | unique index on `statutory_remittances` |
|
||||
|
||||
The inbound-event claim is a **single statement**, not a read-then-write: two
|
||||
concurrent deliveries of the same event would both pass a prior `SELECT`.
|
||||
|
||||
An event that cannot be posted is recorded **FAILED with its full payload** and
|
||||
acknowledged, not dead-lettered — it is already durably stored and replayable.
|
||||
Only a failure to *record at all* nacks, because then the broker holds the only
|
||||
copy.
|
||||
|
||||
---
|
||||
|
||||
## 6. Traps this integration has already paid for
|
||||
|
||||
Each of these was found by an assertion, not by review. They are in the
|
||||
platform's `CLAUDE.md` "Known traps" table.
|
||||
|
||||
| Trap | Consequence |
|
||||
|---|---|
|
||||
| `payment.*` binding | Receives **nothing** — keys are three words |
|
||||
| Summing across currencies | 6.32M overstatement on real data |
|
||||
| `DATE` via a JS `Date` | `toISOString()` returns the previous day east of UTC; a month-end lands in the wrong period |
|
||||
| `invoice_lines.line_total` | The column is `amount`; a plausible name that does not exist |
|
||||
| `iam.employees.first_name` | There is one JSONB `name` column, no first/middle/last |
|
||||
| `CHECK` wider than the column | `varchar(16)` accepted every status until the 17-character one was first used |
|
||||
| Nested transactions | An inner `dataSource.transaction` commits independently and orphans rows |
|
||||
| `is_contra` as a sign flip | Double-flips accumulated depreciation; balance sheet out by exactly 2× |
|
||||
|
||||
---
|
||||
|
||||
## 7. End-to-end flows
|
||||
|
||||
### 7.1 Passenger ticket sale
|
||||
|
||||
```
|
||||
passenger-api payment-api finance-api
|
||||
│ │ │
|
||||
booking ──initiate──────► intent │
|
||||
│ │ │
|
||||
│ provider settles │
|
||||
│ │ │
|
||||
│◄──payment.passenger.succeeded──────────────►│
|
||||
│ │ │
|
||||
confirm booking │ Dr 1114 Gateway clearing
|
||||
issue ticket │ Cr 1122 Trade receivable
|
||||
│
|
||||
monthly ──────────► recognize revenue
|
||||
Dr 1122 / Cr 4210 Ticket revenue
|
||||
(ONE summarised entry per period)
|
||||
```
|
||||
|
||||
Revenue is recognised from `Booking.totalMinor` (genuine cents), **not** from
|
||||
`PaymentIntent.amountMinor` — 18 rows there are 100× overstated by a
|
||||
force-confirm path that writes cents into a major-unit column, and that path is
|
||||
still live upstream.
|
||||
|
||||
The two halves meet at **1122 Trade Receivables**: recognition creates the
|
||||
receivable, the payment clears it. A payment is not revenue, and posting both
|
||||
would count the sale twice.
|
||||
|
||||
### 7.2 Freight shipment
|
||||
|
||||
Same shape, with `payment.freight.*` clearing **1121**. Revenue projects from
|
||||
`freight.invoice_lines.charge_type` through `finance.revenue_mappings` (42 seeded
|
||||
from freight's own 36-value canonical list). An unmapped charge type posts to
|
||||
**4900 Unclassified Revenue** — a real, visible account, so an unexpected balance
|
||||
there is the prompt to add a mapping rather than a silent misclassification.
|
||||
|
||||
Cash receipts read the invoice `payments` **jsonb**, not the `freight.payments`
|
||||
table: the table is a one-row-per-booking gateway-intent projection updated in
|
||||
place, while the jsonb is the only per-settlement ledger that exists.
|
||||
|
||||
### 7.3 Payroll cycle
|
||||
|
||||
```
|
||||
hr-api finance-api
|
||||
│ │
|
||||
calculate run ──► APPROVED │
|
||||
│ │
|
||||
│◄────── read-only projection ───────────────┤
|
||||
│ hr.payroll_runs / hr.payslips │
|
||||
│ │
|
||||
│ Dr 5110 Basic salary
|
||||
│ Dr 5120 Allowances
|
||||
│ Dr 5140 Employer pension
|
||||
│ Cr 2121 PAYE payable
|
||||
│ Cr 2122 Pension payable (ee + er)
|
||||
│ Cr 2130 Salaries payable
|
||||
│ Cr 2160 Other deductions
|
||||
│ │
|
||||
│ disbursement register (the list HR lacks)
|
||||
│ │
|
||||
│ remittance: Dr 212x / Cr cash
|
||||
```
|
||||
|
||||
Only **APPROVED** runs are posted — a run that can still be recalculated would
|
||||
leave the ledger describing a payroll that no longer exists. The entry is dated
|
||||
the period **end**, because the cost belongs to the month worked even when the
|
||||
money leaves later. `gross − deductions = net` is checked explicitly before
|
||||
posting, aggregated from the payslips rather than the run header: the employer's
|
||||
pension is both a debit and part of the credit, and getting that wrong still
|
||||
*balances*.
|
||||
|
||||
Finance stores no copy of the payroll. Only the journal link.
|
||||
|
||||
---
|
||||
|
||||
## 8. Known gaps — things this map does NOT claim work
|
||||
|
||||
Stated plainly, because a map that hides its blank areas is worse than no map.
|
||||
|
||||
1. **Refunds are never executed anywhere.** `freight.payment_refunds` has no
|
||||
writer at all; passenger's `PaymentRefund` is never created; and
|
||||
`BookingCancellation` rows are terminal at creation — `refundStatus` is never
|
||||
updated and `processedAt` never set. The 80% cancellation refund is
|
||||
*computed and recorded* but never paid. Finance therefore posts it as a
|
||||
**liability provision (2150 Refunds Payable)**, never as cash. Do **not** add
|
||||
a `payment.refunded` event until payment-api actually processes refunds.
|
||||
2. **Cash outside the payment rails.** Agent counter cash (confirmed with no
|
||||
PaymentIntent), excess-baggage `CASH_COLLECTED`, freight offline settlements,
|
||||
and wallet top-ups. The wallet one is a control gap: `topUp` credits
|
||||
unconditionally with no payment record and no transaction wrapper.
|
||||
3. **The upstream money bugs were not fixed** — that was a deliberate decision.
|
||||
Finance defends at its own projection boundary instead. The cost is that the
|
||||
defence is permanent: the force-confirm path still writes bad rows.
|
||||
4. ~~**Not verified against a live broker.**~~ **Closed 2026-08-22.** The
|
||||
credentials had never existed: the local broker had only `guest` and the `/`
|
||||
vhost, so both configured URLs failed auth. With an `edr` user and a
|
||||
`payment` vhost created, the whole path was observed end to end — the
|
||||
handler registers as `payment.events::payment.#::finance.payment-events`,
|
||||
both exchanges and both queues are declared durable, and the DLQ really is
|
||||
bound on the DLX. Six events were published through the shared
|
||||
`paymentRoutingKey()` helper the real publisher uses: a passenger receipt
|
||||
posted to 1122, a freight receipt to 1121 (so `payment.#` does catch a
|
||||
second service), a REDELIVERY of the first eventId was deduplicated and did
|
||||
not double-post, `payment.failed` was SKIPPED, a USD payment was recorded
|
||||
FAILED rather than converted at a guessed rate, and an event with no
|
||||
`eventId` was dead-lettered and actually arrived in the DLQ.
|
||||
|
||||
**Mind the vhost.** In an AMQP URI the path IS the vhost and the leading
|
||||
slash is only a separator: `.../payment` means vhost `payment`, not
|
||||
`/payment`. payment-api defaults to `amqp://localhost:5672/payment`, so
|
||||
Finance must be on vhost `payment` too. Neither side errors when they
|
||||
disagree — both connect with `wait: false`.
|
||||
|
||||
**What is still off:** `PAYMENT_RABBITMQ_URL` is absent from finance-api's
|
||||
`.env` and was absent from `.env.example`, so `RevenueModule` skips the
|
||||
RabbitMQ import entirely and live ingest is silently disabled. Both files now
|
||||
document it. Setting it (plus `FINANCE_DEFAULT_ORG_ID`, without which the
|
||||
consumer posts against `""` and every account lookup fails) is what turns
|
||||
ingest on — that is go-live checklist step 3.
|
||||
5. **Freight billing and `edr_payment` are absent from the dev replica.** Their
|
||||
projections are shape-validated against entity-derived tables, not real data.
|
||||
6. ~~**No browser render check** on any Finance screen.~~ **Closed 2026-08-22.**
|
||||
All 13 screens were driven in Chromium: 20 tabs, 11 modals, and a full
|
||||
draft → post → reverse journal cycle checked against the trial balance,
|
||||
P&L, balance sheet, cash and general-ledger reports. No page errors and no
|
||||
failed requests. Four defects were found and fixed, all invisible to
|
||||
type-check and build — the largest being that the theme toggle drove only
|
||||
Tailwind's `dark` class and never Mantine's colour scheme, so every page
|
||||
rendered black-on-black in dark mode. Still NOT checked: the write paths for
|
||||
suppliers, bills, assets, depreciation and budgets were opened but not
|
||||
submitted, and no screen has been viewed below 1440px.
|
||||
7. **P7 cutover: the tooling is BUILT (2026-08-22), the migration is not RUN.**
|
||||
`finance.org_settings` holds a per-organization cutover date;
|
||||
`GET /api/v1/cutover/readiness` answers five questions with live numbers
|
||||
(suspense zero, no operational entry before the boundary, register agrees
|
||||
with the ledger, migrated assets carry a period count, date set);
|
||||
`POST /api/v1/cutover/opening-balances` turns pasted rows into a DRAFT
|
||||
OPENING entry with the 3900 plug **calculated, never supplied**; and the
|
||||
payment consumer now SKIPs any event settled before the cutover, recording
|
||||
the reason — without that, a replayed backlog would post money the opening
|
||||
balances already carry. Screen at `/cutover`.
|
||||
|
||||
What remains is the business act, not a build: choose the date, enter the
|
||||
real balances, and post them. Two traps were found and fixed while building
|
||||
this — a migrated fixed asset would silently never depreciate again, and the
|
||||
suspense check was counting DRAFT lines because a status filter sat in a
|
||||
LEFT JOIN's ON clause.
|
||||
|
||||
---
|
||||
|
||||
## 9. Before going live
|
||||
|
||||
1. Run freight's migrations against the target database so billing tables exist.
|
||||
2. Boot payment-api so `edr_payment` is created, and confirm the broker vhost.
|
||||
3. Set `PAYMENT_RABBITMQ_URL` and `FINANCE_DEFAULT_ORG_ID` on finance-api; verify
|
||||
a real event reaches `finance.payment-events`.
|
||||
4. Seed the chart of accounts and revenue mappings per organization.
|
||||
5. Create the fiscal year (8 Jul – 7 Jul) and its periods.
|
||||
6. Set the cutover date on `/cutover` **before** entering anything — the
|
||||
consumer needs it to know which events are already history.
|
||||
7. Enter opening balances on `/cutover`, in as many batches as suits (cash,
|
||||
receivables, payables, equity). Each becomes a DRAFT you post yourself; the
|
||||
difference goes to **3900 Opening Balance Suspense**, which must end at
|
||||
**zero**. That is the check that the migration was entered correctly.
|
||||
Migrate fixed assets through the register with their accumulated
|
||||
depreciation **and** their opening period count, and with no funding account
|
||||
— the ledger side comes from the opening entry.
|
||||
8. Work the readiness panel until every check passes.
|
||||
9. Reconcile the first period end: trial balance balances, balance sheet
|
||||
balances, and the asset register agrees with the ledger.
|
||||
@@ -244,7 +244,7 @@ Without these, every locator hangs off role/text/`name=`/placeholder, which is b
|
||||
|
||||
1. **Valid IAM token for storageState** — Path A (real `/v1/auth/login` after enabling `SEED_EDR_PASSENGER_ORG` + `SEED_PASSENGER_STAFF`) vs Path B (direct `iam.sessions` insert with `userInfo.roles=[{key:'super_admin'}]` + self-signed JWT). **Recommend Path A for staff, Path B acceptable for passenger.** Confirm.
|
||||
2. **Seed `iam.sessions` vs dev bypass** — there is **no dev auth bypass** in the passenger-API `JwtGuard` (DB-backed, no env short-circuit). A session row is mandatory for any authenticated flow. Confirm we may write directly to `iam.sessions` in the test DB.
|
||||
3. **Target DB / stack** — doc drift: CLAUDE.md says `postgres-passenger:5434/edr_passenger`; `.env.example` says `localhost:5432/edr_database?schema=passenger`; `.env.test` uses `5544`; no compose file provisions it. **Confirm the harness stands up its own Postgres :5544 + boots both APIs, or targets an existing dev stack.**
|
||||
3. **Target DB / stack** — resolved: dev is the single schema-separated database at `localhost:5432/edr_database` (`?schema=passenger` for Prisma), provisioned by `infrastructure/docker/docker-compose.db.dev.yml`. The harness deliberately does NOT use it — it stands up its own hermetic Postgres on 5544 (`e2e/docker-compose.yml`, `.env.test`) and boots both APIs against that.
|
||||
4. **Stack-startup reliability** — global-setup must boot passenger-api (:4000) + payment-api (:3003) + portal (:5174) + backoffice (:5184) + RabbitMQ (vhost `payment`), or route settlement through `/internal/payments/mark-paid` to avoid RabbitMQ. **Recommend the internal-endpoint path for green settlement determinism** — but note it will **not** reproduce a *late*-webhook race (C-5) nor the charge-currency conversion (DJF, UA-3), which both require a real forged-gateway webhook to :3003.
|
||||
5. **Gateway webhook signing** — Telebirr/dmoney accept forged payloads (`signatureValid=true` hardcoded); Card/Waafi require valid HMAC. UA-3/UA-15/gateway rows must use Telebirr/dmoney or the internal endpoint. Confirm we won't need real Card/Waafi HMAC in Phase 2.
|
||||
6. **Fayda flag & prefix** — global-setup must set `VERIFAYDA_ENABLED=false` (else the manual passenger form is hidden and every booking flow blocks). **Confirm which `fayda-status` route the portal reads** (`/config`, default-ON, vs `fare-engine.controller.ts:65`, default-OFF) so the correct flag is set.
|
||||
|
||||
Reference in New Issue
Block a user