Files
edr-platform/docs/integration-map.md
2026-08-25 00:11:39 +03:00

20 KiB
Raw Permalink Blame History

Integration map — HR, Finance and the operational systems

Section 5 of the ERP expansion. Written 2026-08-22, after HR (3.13.7) and Finance (4.14.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.