20 KiB
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.
-
Refunds are never executed anywhere.
freight.payment_refundshas no writer at all; passenger'sPaymentRefundis never created; andBookingCancellationrows are terminal at creation —refundStatusis never updated andprocessedAtnever 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 apayment.refundedevent until payment-api actually processes refunds. -
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:topUpcredits unconditionally with no payment record and no transaction wrapper. -
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.
-
Not verified against a live broker.Closed 2026-08-22. The credentials had never existed: the local broker had onlyguestand the/vhost, so both configured URLs failed auth. With anedruser and apaymentvhost created, the whole path was observed end to end — the handler registers aspayment.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 sharedpaymentRoutingKey()helper the real publisher uses: a passenger receipt posted to 1122, a freight receipt to 1121 (sopayment.#does catch a second service), a REDELIVERY of the first eventId was deduplicated and did not double-post,payment.failedwas SKIPPED, a USD payment was recorded FAILED rather than converted at a guessed rate, and an event with noeventIdwas 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:
.../paymentmeans vhostpayment, not/payment. payment-api defaults toamqp://localhost:5672/payment, so Finance must be on vhostpaymenttoo. Neither side errors when they disagree — both connect withwait: false.What is still off:
PAYMENT_RABBITMQ_URLis absent from finance-api's.envand was absent from.env.example, soRevenueModuleskips the RabbitMQ import entirely and live ingest is silently disabled. Both files now document it. Setting it (plusFINANCE_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. -
Freight billing and
edr_paymentare absent from the dev replica. Their projections are shape-validated against entity-derived tables, not real data. -
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'sdarkclass 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. -
P7 cutover: the tooling is BUILT (2026-08-22), the migration is not RUN.
finance.org_settingsholds a per-organization cutover date;GET /api/v1/cutover/readinessanswers 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-balancesturns 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
- Run freight's migrations against the target database so billing tables exist.
- Boot payment-api so
edr_paymentis created, and confirm the broker vhost. - Set
PAYMENT_RABBITMQ_URLandFINANCE_DEFAULT_ORG_IDon finance-api; verify a real event reachesfinance.payment-events. - Seed the chart of accounts and revenue mappings per organization.
- Create the fiscal year (8 Jul – 7 Jul) and its periods.
- Set the cutover date on
/cutoverbefore entering anything — the consumer needs it to know which events are already history. - 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. - Work the readiness panel until every check passes.
- Reconcile the first period end: trial balance balances, balance sheet balances, and the asset register agrees with the ledger.