One conflict, in booking-notifier.service.ts's scheduleLabel(). Both branches
had independently gone after the same thing — customers know a departure by
its operating number, not the S-… schedule reference — so the resolution takes
dev's version wholesale rather than splicing.
Ours was a one-line flip, `s.trainNumber ?? s.reference`. Dev reaches the same
result via `const number = s.trainNumber ?? s.reference` and goes further: it
keeps the reference as a secondary detail beside the route, suppresses that
detail when the number IS the reference so it cannot print twice, and gives the
departure a time of day and an EAT suffix instead of a bare date. Nothing in
ours is absent from theirs. This branch's own additions to the file —
trainNumber(), trainTag() and the scheduleId threading — are untouched by the
conflict and survive intact.
Renumbered the two DJF migrations, which the merge put on top of dev's:
3790000000000 → 3840000000000 (DjfCurrencySupport)
3800000000000 → 3850000000000 (PerCurrencyExchangeSettings)
Dev landed its own migrations at 3790 and 3800, so both of mine collided; the
new prefixes are unused and above dev's highest (3830000000000), per the rule
in CLAUDE.md.
Fixed a real defect the renumber exposed. PerCurrencyExchangeSettings read
`djf_enabled` unconditionally to carry the operator's DJF choice forward — but
it DROPS that column at the end of its own up(), so it could only ever run once.
Re-running it, or running it against a database repaired by hand, died on
42703 "column djf_enabled does not exist". The read is now guarded on
information_schema, defaulting to false when the column has gone, and the
insert's ON CONFLICT DO NOTHING leaves an existing DJF row's choice alone.
Verified: migrations run clean to "no pending freight migrations" (both
recorded, ids 379/380); exchange_settings holds ETB/USD/DJF with the unique
index and djf_enabled dropped; DJF stayed enabled, so the operator's toggle
carried across. Type-check passes on freight-api, payment-api,
payment-providers, ui-common and portal. Freight suite: 10 suites / 28 tests
failing against a pre-merge baseline of 9 / 27 — the one addition is dev's own
freight-permissions.registry.spec, whose registry and spec are byte-identical
to dev's here and which this branch never touched. No currency, exchange,
pricing, billing or overview spec fails.
"Total collected" added ETB to USD-converted-to-ETB and stopped there, so
money collected in any third currency was absent from the headline figure
while still sitting in the invoice list underneath. Same shape as the
overview dashboards fixed in 829918126.
Converts each non-birr currency at the live rate, falling back to that
currency's own stored rate when CBE is down, and adds the DJF tile beside
the existing ETB and USD ones.
A currency with no usable rate is left OUT of the total and the hint says
so, rather than being added at face value — treating 1 DJF as 1 ETB would
overstate it about a hundredfold, which is worse than an obviously
incomplete number.
DJF was priced off a rate nobody could see or set. The rate itself worked —
CBE quotes DJF and the provider read it — but `exchange_settings` held a
single USD→ETB row, `getStatus()` hardcoded `rates.get("USD")`, and the
settings card was titled "Exchange rate (USD → ETB)". So an operator could
enable DJF billing, have invoices priced in francs, and have no way to see
which rate produced them and no way to override it during an outage. Those
are exactly the two levers they have for USD.
The original reasoning — DJF is pegged to USD, so a second stored number is
a second thing that goes stale — justified not storing a second FALLBACK.
It never justified not displaying the rate. Those got conflated.
`exchange_settings` becomes one row per currency. `fallback_rate` means the
same thing in every row: ETB per one unit of `currency`. The ETB row is 1
and exists so the table describes the whole set rather than "the others".
`enabled` generalises `djf_enabled` from 3790000000000 — availability is a
property of a currency, not a column named after one — and the migration
carries the old flag onto the DJF row so an operator who had already
switched it on does not find it off after deploying.
loadFallbackRate/saveFallbackRate take a currency. The provider persists
every quoted rate from the one payload it already fetched, so DJF has its
own last-known-good value to fall back on, and reads that currency's own
stored rate first — a rate an operator typed by hand during an outage must
not be silently re-derived from USD. The peg survives one rung lower: when
a currency has nothing stored, USD's stored rate through the peg still
beats the compiled-in default, which is a year-old number by definition.
The DTO's `@Min(1)` is now `@Min(0.0001)`. It would have rejected every
legitimate DJF rate — birr per franc is about 0.92.
Verified against the dev database: one live fetch stamped last_synced_at on
both the USD and DJF rows, which is the behaviour that was missing.
Exercising DJF through the portal means enabling the flag, building a
booking, and clicking through the wizard before a single franc figure
appears. This prices an existing booking three ways instead and prints the
lines side by side, so the rate feed, the conversion, the zero-decimal
rounding and the cross-currency parity are all visible in one command.
Read-only: computePriceForBooking persists nothing and the currency is
flipped on the in-memory entity, so it is safe against a shared database
and safe to re-run.
pnpm --filter @edr/freight-api exec ts-node -r tsconfig-paths/register \
src/scripts/check-djf-pricing.ts BK-2026-000221
The parity line is the check worth reading: the same freight quoted in ETB
and in DJF must come back to the same money once converted. Anything past
rounding drift means a rate or a conversion is wrong.
buildExportLoadListHtml's wagon table (origin export doc, Marshalling
2/3, and the intercity fallback) had no station columns — only the
import doc did. Adds Departure Station / Arrival Station per row,
mirroring the import doc's existing pattern: a whole-route wagon reads
the schedule's own origin/destination, a leg-slot wagon reads its own
boardYardId/alightYardId instead (resolved via a new yardLabelById
opt, computed once per document from board+alight yard ids across the
trainSet).
Also fixes two pre-existing off-by-one colspans on the EMPTY row and
the 'No wagons on this train set' placeholder, now that the real
column count changed with the two new ones.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Marshalling 2's wagon list was computed from intercityOnBoardView's
CURRENT state (boardYardId == null || hasLoaded), which can't tell
'coupled at THIS stop' from 'coupled at a LATER stop that has, by
generation time, also already happened' — both read LOADED once the
trip has moved on. Reprinting Marshalling 2 (Dire Dawa) after
Marshalling 3's stop (Adama) had already run leaked Adama's 5 coupled
wagons into Dire Dawa's document (54 wagons became 59), reproduced
live on S-2026-00073.
marshallingDocumentAt now corrects the on-board list against the
adjustment log: a leg-slot wagon belongs on a stop's document only if
it has a logged ADD at or before that stop's own timestamp. Extracted
as wagonsAsOfStop so it's unit-testable without mocking the DB.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Leg-slot wagons planned to couple at a LATER stop were being counted
into the Wagons/Allocations/Total containers tiles on the ORIGIN
marshalling document (Import Load List, export load list) — inflating
the departing count from 54 to 64 on a real train, plus a 'TO BE
LOADED AT X' / 'TO LOAD AT X' row and a separate 'To load en route'
tally to work around it.
Filters those slots out of buildImportLoadListHtml / buildExportLoadListHtml
entirely instead: a wagon not part of the departing consist gets no
row and no count on this document, full stop. Its own coupling shows
up on THAT stop's own numbered marshalling document once it actually
happens (intercityOnBoardView already filtered correctly there,
unaffected). Deletes the now-dead pendingBoardYardLabelBySlot special-
casing, loadsHere guards, and the 'to load en route' tile.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PROVIDER_CURRENCIES was permissive for everything except CBE_BILL, on the
reasoning that the adapters pass input.currency through verbatim and
nothing in them refuses a currency. That was the wrong place to look. The
configuration says otherwise, and so does the domain: Telebirr is Ethio
Telecom, CBE Birr is a Commercial Bank of Ethiopia wallet, and neither
settles Djiboutian francs. The table as written would have let a DJF
freight invoice be routed to one of them.
payments.service.ts already states the rule above its chargeCurrency
resolution — "WAAFI/DMONEY settle in DJF, CARD in USD, Ethiopian wallets
in ETB" — and fix-payment-method-currency.ts corrected exactly WAAFI to
DJF and CARD to USD, deliberately leaving the Ethiopian methods on the
schema's ETB default. eBirr is configured as one of those: seeded
region ETHIOPIA, never corrected.
So TELEBIRR, CBE_BIRR, CBE_BILL and EBIRR are ETB-only. WAAFI, DMONEY and
CAC_BANK stay open rather than pinned to DJF — that is their configured
settlement currency, not a refusal of anything else, and passenger
converts to each method's own currency before initiating regardless. CARD
stays open too; its /100 minor-unit handling is a separate problem.
Adds the spec that was missing: DJF must not reach a birr rail.
A booking is routinely loaded in parts, and nothing told the customer which
containers boarded and which stayed behind. The carriage acceptance sheet was
the only record, and it both totalled up cargo still sitting in the yard and
lived inside a Warehouse documents bundle that direct truck-to-train cargo
has no business in.
The sheet now marks each wagon Loaded or Not loaded and totals only the loaded
ones. On load, the customer gets an in-app, SMS and email notice carrying the
train number, route, departure time and both container lists — capped to a
summary on SMS and email, complete in the inbox. Anything left behind also
raises a warehouse-desk notice so somebody owns finding it space.
That desk is addressed by a new warehouse_inventory:get_notification
permission: a recipient selector, not a route guard, so ops can assign who
gets pinged without granting access to anything.
The GRN notice went out over SMS alone, to whatever phone number the gate
clerk typed. Where the receive carries a booking it now resolves the company
and delivers in-app, SMS and email, skipping the typed phone so the customer
is not texted twice; manual and backlog receives keep the old path.
The portal's type-check script was `tsc --noEmit`, run against a
tsconfig.json whose "files" is [] and which only carries project
references. tsc checks zero files and exits 0, so
`pnpm turbo type-check --filter=@edr/freight-portal` has been reporting
success without compiling anything.
Switched to `tsc -b`, which the backoffice already uses and which follows
the references. The portal is clean under it.
Every currency picker decided for itself which currencies existed, from a
hardcoded pair, so an administrator's setting and the form could disagree
and the customer would only find out on submit. They now read
GET /exchange-settings/currencies.
CurrencySelector's allowUsd boolean becomes an allowed list. The caller is
choosing on two independent axes — trade direction (export and intercity
invoice in ETB whatever is picked) and what is switched on — so a second
boolean would have needed a third one next time.
Adds the DJF card and option with its own hint, the DJF tab on the finance
hub and the DJF row on the manual-payment settings card, both driven by
the per-currency flag rather than an if/else on two currencies. The
exchange-rate settings card gets the toggle itself, with the wording that
turning it off stops new choices rather than changing bookings already
priced in DJF.
The inline "ETB" | "USD" unions on the invoice filter and the customer
shipment and payment types are widened, so a DJF invoice is not mistyped
on arrival.
formatMoney needs no change — it already defaults to 0 fraction digits,
which is correct for DJF.
Two more hardcoded ETB/USD option lists, one on the shared revenue-report
filter and one on the contracts export dataset. A report that cannot be
filtered to a currency never shows that currency's revenue, which reads as
an empty result rather than an error. currencyOf() still defaults to ETB
when the caller picks nothing.
The only currency rule anywhere was CBE_BILL's, spelled out twice — once
inside initiateCbeBill and again in the freight API before it calls the
payment service. Two copies of one provider's rule, and no check at all
for the other seven. initiate-payment.dto accepted any 3-to-8 character
string as a currency.
Both copies now read PROVIDER_CURRENCIES, and the check sits once in
initiate() before any provider opens a session, so it covers all of them.
The DTO gets the whitelist it never had.
Amount precision follows the same rule. eBirr and D-Money formatted at a
fixed 2dp regardless of currency; D-Money would have sent "71088.00" for
a DJF order, and DJF has no centimes to send. Both now format at the
currency's own precision. CAC Bank already did this and documents it.
Waafi is deliberately left alone: its toAmount() truncates rather than
rounds, the comment says that is intentional, and changing it would alter
an existing money path for a reason unrelated to DJF. Same for the
amountMinor major/minor unit disagreement in cbe-birr and card — real,
pre-existing, and unreachable from DJF since both are ETB rails.
Seven selects in overview.repository sum payments with a literal currency
filter, one column for ETB and one for USD:
COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)
Anything else is summed nowhere. A DJF payment would not appear as a
separate figure — it would simply be absent from revenue MTD, the payment
trend, the per-method breakdown and the direction and freight-type splits,
with no error and nothing to notice.
Adds the third column at each site, the matching response fields, and the
tiles and stacked bar that render them. The payment chart's tooltip now
derives the label from the series key instead of a ternary on
"amountUsd", so it does not need touching again.
ponytail: a third hardcoded currency is still the shorter diff. A fourth
should force these seven into a GROUP BY payment.currency returning
IOverviewCurrencyAmount[] — the shape getRevenueByCurrency already uses a
few lines below.
Note the ordering dependency: the DJF enum label must exist before these
run. payments.currency is enum-typed, so `= 'DJF'` against a database
where the migration has not been applied is a runtime error, not an empty
result. All three query shapes were EXPLAIN-checked against the dev
database.
Pricing carried one number, usdToEtb, gated on a boolean:
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await getRate('USD', 'ETB') : 1;
Four entry points repeated it, and every conversion was a ternary on that
boolean. A third currency has nowhere to go in that shape.
Replaced with a USD-to-x rate map resolved once per pass, and a small
moneyIn() helper returning the two conversions the line builders actually
use. A USD booking stays the identity case and is left unrounded exactly
as before — rounding it now would shift totals on bookings this change is
not meant to touch. Everything else rounds to its own currency's
precision, which is what makes DJF come out in whole francs.
frozenRateByCode took the same scalar and hardcoded USD to ETB and ETB to
USD, returning null for any other pair — which would have silently dropped
an agreed contract price on a DJF booking and re-priced it at live rates,
the exact bug the USD/ETB conversion was added to fix. It now crosses the
two USD legs, so any pair converts, and keeps the guard that returns null
on a missing or 0/NaN rate rather than zeroing a line.
booking-wagon-cancellation collapsed anything that was not ETB to USD:
const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
That billed a DJF booking's cancellation fee in dollars. It now uses the
booking's own currency, narrowed against the shared list.
warehouse-fee's normalizeCurrency did the same collapse, and rounded
conversions to 2dp regardless of target. Invoice totals and the gateway
amount round by currency too — a fractional franc is malformed, not
precise. round2 is left alone everywhere it already serves ETB and USD.
Adds DJF to the currencies a booking or shipment can be billed in, off by
default: going live with it is Finance's call, not a deploy's.
Schema. Every currency column in freight is already a varchar that fits
'DJF' except payments.currency, the schema's one enum-typed currency
column, which would reject the value outright — so the migration adds the
enum label. PG 12+ allows ADD VALUE inside a transaction (migrations run
with transaction mode 'each') as long as the new label is not used in the
same one, and nothing here inserts it. down() drops only the flags:
Postgres cannot remove an enum label, and trying would orphan any row
already written with it.
Two flags, because they answer different questions. exchange_settings
.djf_enabled gates whether DJF is offered at all and starts off.
manual_payment_settings.djf_enabled gates bank-transfer settlement and
starts on — a DJF invoice has to be settleable the day the first one is
raised, which is exactly why USD has always started on.
Enforcement. class-validator cannot see a database flag, so the static
whitelists admit DJF and ExchangeSettingsService.assertCurrencyAllowed()
decides whether it is live. It is called where a currency is chosen —
booking create and update, and shipment creation under a contract — not
where one is read. Only the requested currency is checked, never the
resolved one, so switching DJF off stops new choices instead of bricking
shipment creation on contracts already written in it. Contract creation
needs no check: contracts are always quoted in USD and ignore a
client-supplied currency.
resolveShipmentCurrency stays pure and synchronous; a checked async
wrapper sits beside it, resolved before the insert callbacks (which are
sync, and re-run on a reference collision) rather than inside them.
GET /exchange-settings/currencies is readable by customers as well as
staff, so the portal's picker can offer exactly what the API will accept
instead of a hardcoded pair that fails on submit. PATCH now leaves an
omitted field alone, so flipping the toggle does not re-stamp the fallback
rate as MANUAL as a side effect.
CbeExchangeProvider fetched the CBE daily-exchange-rates payload, read the
USD entry out of it and threw the other seventeen away — getBaseRate()
returned null for anything but USD to ETB. The feed already publishes DJF
in that same payload (0.9203 ETB per DJF today), so serving more than one
pair costs no extra request.
The provider now parses the whole record into a code to ETB map and caches
that, keyed by ISO code. Entries CBE publishes as 0 or null are skipped
rather than stored — a zero rate would silently zero an invoice line.
ExchangeService gains a fourth resolution step. DJF to USD is neither a
direct pair nor an inverse of one, because the provider only ever quotes
against ETB, so the two ETB legs are crossed instead of the pair being
declared unavailable.
Fallbacks stay a single stored number. DJF is hard-pegged to USD at
177.721, so the offline legs derive from the stored USD rate through the
peg — that reproduces CBE's own DJF quote to four decimals, and a second
persisted rate would only be a second thing that can go stale.
getRatesFromUsd() resolves a whole pricing pass's conversions up front so
line builders do not await inside a loop. Because it asks for several
codes concurrently, a cold cache would have opened one HTTP request per
currency for the same payload; an in-flight promise is now shared.
The spec lives in freight-api because api-common has no test setup of its
own, and adding one for a single file is not worth the framework.
PAYMENT_CURRENCIES was copy-pasted into six files — four freight DTOs and
two portal zod schemas — with no owner, and @edr/api-common's exchange
service kept a seventh copy of its own as a bare "USD" | "ETB" union. They
had already drifted: the DTOs and the exchange service did not agree on
what the platform could price in, so a form could offer a currency the
rate service had never heard of.
Adds PAYMENT_CURRENCIES (now including DJF), the PaymentCurrency type and
an isPaymentCurrency guard for the free-string currency columns the ORMs
hand back. Every existing list re-exports this one instead of becoming a
seventh copy.
Also carries the two rules that have to travel with the list:
CURRENCY_DECIMALS, because DJF is a zero-decimal currency — an amount with
centimes is not a more precise payment, it is a malformed one, and CAC
Bank rejects it. roundMoney() applies it.
PROVIDER_CURRENCIES, which gateway settles which currency. Only CBE_BILL
carries a restriction the code can evidence (CBE settles ETB, plan D8);
eBirr, Waafi and D-Money each pass input.currency to the provider verbatim
with an explicit comment saying so. The other rows are therefore
permissive on purpose rather than guessed — settlement currency is really
a property of the merchant account, not the provider brand, and tightening
a row on a hunch would break a live flow.
Every customer notice about a train said "your train" or nothing at all —
the customer had no way to tell which departure a dispatch, pay window,
cancellation or reschedule referred to. Only secured() named one, and it
quoted the schedule reference (S-YYYY-NNNNN) rather than the train number
that yards and customs actually use.
Adds trainNumber()/trainTag() beside the existing scheduleLabel(), reusing
the TrainSchedulesRepository already injected, and threads the number
through dispatched, arrived, payNow, payDeadlineApproaching, payNowPartial,
remainderPlaced, expired, displaced, rescheduled, allocatedOtherDay,
removedFromTrain, scheduleCancelled and maintenanceMoved. scheduleLabel now
prefers train_number over the schedule reference. Falls back to the
reference while a number is unassigned, and to the previous wording when
the schedule cannot be loaded — never a UUID.
Several of these fire after the booking has been detached from its
schedule (cancel, remove, expire all clear train_schedule_id before the
notice goes out), so each method takes an optional trailing scheduleId and
those callers pass the schedule the booking was just pulled off. Sync
signatures are preserved with the void-async wrapper secured() already
used, so no call site changes beyond the extra argument.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A481tjLb6zEkLnk4c4wtVR
Optional IAM account per agent — nullable, nothing backfilled, so
roster-only rows keep working. Staff invite existing ones; new ones get
an account when an email is supplied. Activation reuses the shipping-line
link path. SMS reachability widens to Djibouti.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Surfaces the physical stack/slot model in the staff app.
- ZoneLayoutModal: stacks drawn level by level with occupancy colours,
configured-vs-built-vs-occupied counts, and stack create/delete plus
block/reserve/free on empty levels
- SlotPicker in the store and move modals, offering only the next
fillable level of each stack so the form cannot suggest a position
the API will refuse
- move modal warns when a container is buried, lists the blockers, and
disables the action instead of firing a 409
- fix: move() now asserts accessibility server-side, matching release —
both are exits from a stack
Extends the warehouse hierarchy below zone with ground stacks and vertical
slots, so a container's exact position is recorded rather than only its zone.
- freight.warehouse_zone_stacks / warehouse_zone_slots, plus nullable
stack_id / slot_id on warehouse_inventory (existing rows stay valid)
- slot occupancy is derived from inventory status, guarded by a partial
unique index, so no exit path has to remember to free a slot
- placement service: hierarchy validation, bottom-up stacking rules,
accessibility/blocking-container reads, capacity vs slot summaries
- stack CRUD with auto-generated slots; reuses warehouse-zone permissions
- slot support folded into the existing move()/store() paths
- fix: validateLocation now rejects a mismatched warehouse/yard/zone triple
- seed:warehouse-layout builds the layout from a JSON config
Nullable freight_type on the warehouse, null meaning it takes both.
No backfill: every existing warehouse is unrestricted today and
writing a value would narrow allocation behind the operator's back.
Reuses FREIGHT_TYPES from the booking entity rather than a third copy
of the same two values.
Yard and zone soft-delete, refused with 409 while a yard still has
zones or a zone still holds inventory. warehouse_zones:delete was
missing from the catalog — the role presets spread every zone key, so
its absence crashes FreightPositionsSeeder at boot; a migration seeds
it everywhere.
Clicking a zone opens its contents as a datatable. Container identity
comes from booking_container_units for booked cargo and from
containers for backlog registrations; bulk cargo keeps its row with no
container number rather than disappearing from the zone.
Soft-delete route guarded by warehouses:delete, refused with 409 while
yards remain — zones and inventory hang off a yard, so cascading would
orphan stock. Backoffice list gets a delete action in both views,
omitted when the user lacks the permission.
The import template was a link inside the bulk upload modal, so getting the
template meant opening a dialog that asks for the file you do not have yet.
It becomes a Download Template button next to Bulk Upload; the modal keeps
only its one-line explainer.