Commit Graph

5047 Commits

Author SHA1 Message Date
Nathnael
a119b09322 feat(payments): check every gateway against the currency it settles
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.
2026-08-29 08:13:31 +00:00
Nathnael
8299181261 fix(overview): stop non-ETB/USD revenue vanishing from the dashboards
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.
2026-08-29 08:13:19 +00:00
Nathnael
df221a873d refactor(pricing): price in the booking's currency, whatever it is
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.
2026-08-29 08:13:07 +00:00
Nathnael
f1d1cfb127 feat(billing): accept DJF as a billing currency, behind a toggle
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.
2026-08-29 08:12:52 +00:00
Nathnael
b3f89a41b9 feat(exchange): serve every currency CBE quotes, not just USD
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.
2026-08-29 08:12:33 +00:00
Nathnael
427665f5b2 feat(types): own the currency list in one place
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.
2026-08-29 08:12:21 +00:00
Nathnael
3a298b5fce feat(notifications): name the train in booking notifications
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
2026-08-29 08:04:16 +00:00
Nathnael
df8e10e041 feat(bookings): surface cargo declared on the shipment request
A GENERAL + customs contract does not let the customer book directly: they
submit a shipment request, and initiateForShipmentRequest opens a BARE booking
from it — "the request itself carries the quantities; the instance carries
none". Between initiation and completeUnderContract the booking legitimately
holds no cargo, so the export reported 0 containers for a customer who had
declared, say, 2 x 20FT. 23 bookings on dev data are in that state.

Adds two columns and one filter reading booking_requests.requested_lines:
- "Requested cargo" — the declared lines as text ("2 x 20FT"), handling the
  bulk shape too (tons / item count), not only containers.
- "Requested containers" — the declared box count, with a matching min/max
  filter on the list and the export.

Deliberately a separate column rather than a fallback inside the real container
count: a declared 2 x 20FT is a request, not two boxes on a booking, and
merging them would overstate operational totals. The two compose instead —
Containers = 0 AND Requested containers >= 1 is exactly the set awaiting
completion after clearance.

requested_lines is free-form jsonb, so the container array is guarded by
jsonb_typeof before jsonb_array_elements; one malformed row would otherwise
500 the whole list.
2026-08-28 11:23:52 +00:00
Nathnael
aa3100d161 Merge branch 'dev' into freight/nati-2 2026-08-28 11:08:35 +00:00
Nathnael
74fb05207c feat(bookings): filter by container count, export per-type quantities
Container filters on the booking-requests list:
- "Container type" — bookings carrying that type.
- "Containers" — a count of BOXES (booking_container is one row per line with
  a quantity, so this sums quantity rather than counting rows), as an exact
  value or a range. It reads the container-type filter when one is set, so the
  one control answers both "10 containers in total" and "10 forty-footers".

Export gains a column per container type ("20FT containers", "40FT
containers"), plus the total "Containers" column and the two filters. Container
types are reference rows, not a constant, so `ExportDataset` gains an optional
`dynamicFields` resolver — DB-driven columns appended to the static list and
cached for the process, mirroring the existing `ExportFilterDef.optionsQuery`.
Adding a 45ft container type adds its column with no code change. The type id
is interpolated into raw SQL (ExportField.select has no parameter bag), so the
resolver drops any id that is not a uuid.

Also repoints the export's "Container VGM" column at the per-line sum. It was
projecting bookings.cargo_total_weight_vgm, which the portal wizard leaves at 0
for container freight — the same trap the tonnage fix addressed — so the column
read 0 for every portal-created container booking. Non-zero on dev data goes
from 54 to 170 of 208 container bookings.
2026-08-28 10:02:01 +00:00
Nathnael
339b8a8682 feat(bookings): filter and export booking content
The booking-requests list could filter by freight type but not by what is
actually in the booking, and the export's only cargo column showed the
commodity name — blank for every container booking, which stores no
commodity at all.

Adds one resolver, `bookingContentSql`, that answers "what did the customer
say is in this booking" per freight type: the container lines they entered
("2 × 40FT, 1 × 20FT") for container freight, since the wizard asks them for
no description; the commodity they picked from the cargo tree for bulk,
falling back to their free-text description.

List filters:
- "Content" — a single select flattening the cargo tree the same way the
  booking wizard presents it (group, then each commodity as "Bulk → Wheat").
  Picking a GROUP matches its whole subtree via a recursive walk, so "Bulk"
  returns all 44 bulk bookings rather than the 0 that carry the group id
  itself. This makes the existing, previously unexposed `cargoTypeId` param
  group-aware.
- "Content contains" — a contains-search over the description, the commodity
  name and the container types, so container bookings are reachable by "40FT"
  even though they carry no words of the customer's own.

Both apply through `applyListFilters`, so the list, its summary tiles and its
facets agree, and both are declared on the bookings export dataset — the
export button already forwards the page's filters verbatim.

Export fields: "Content" (default), plus "Cargo description" as its own
column. The old `cargo` column is unchanged and still selectable, relabelled
"Cargo (commodity)"; it loses only its default tick, so saved presets that
name it keep working.
2026-08-28 09:42:56 +00:00
Nathnael
0e9cfbce66 fix(reports): count container tonnage in exports and reports
Every SQL tonnage in the export datasets and report definitions used
`COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)`. COALESCE
falls through on NULL, never on 0 — and the portal booking wizard stores
`cargo_total_weight_vgm = 0` for container freight on purpose, because VGM
is captured per container line, not as a booking-level figure. So every
portal-created container booking reported as weighing nothing. The
backoffice wizard does store a booking-level total, so the same table holds
both shapes and the numbers looked erratic rather than uniformly zero.

Extract the resolver the TypeScript side already has three copies of
(bookingCargoTons, cargoTonsAndItems, totalVgmTons) into one SQL helper:
NULLIF both booking-level columns, then fall back to
SUM(booking_container.total_vgm_tons). Applied to the bookings and
train-schedules export datasets, the cargo-summary, contract-utilization and
booking-status-breakdown reports, and the intercity booking list.

On dev data this recovers 116 of 154 zero-weight container bookings and
raises live booking tonnage from 42,973 t to 61,424 t.
2026-08-28 09:20:46 +00:00
Nathnael
a6b2519136 feat(billing): filter invoices and manual payments by invoice type
Adds a free-form `types` CSV filter to the invoice list DTO and query
(same treatment as `paymentMethods` — each billing source mints its own
type string, so an IsIn would drop real values), carries it into the
invoices export dataset, and surfaces a Type column plus filter pill on
both the Invoices and Manual Payments tables.
2026-08-28 09:17:41 +00:00
Nathnael
78c6f8be0a feat(bookings): show the invoice number in Pricing & payment
The booking's freight invoice is looked up through the existing invoice
list endpoint (source=booking, sourceId matched by search), newest first
so a re-issue supersedes the old number.
2026-08-28 09:17:35 +00:00
marshal
081f66fb82 Merge pull request #1434 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
2026-08-28 10:34:31 +03:00
Marshal
ba56974e32 feat(train): enhance train history and scheduling features
- Added a reason field to train history entries for detach/maintenance actions.
- Updated TrainHistoryPanel to display the reason for wagon detachments.
- Introduced per-wagon load/unload functionality in ScheduleWorkspacePanel with a modal for managing individual wagons.
- Implemented API endpoints for loading and unloading specific wagons, including the ability to cancel remaining wagons with a reason.
- Refactored detach request handling in TrainBuilderDetailPage to streamline the process and remove the approval flow, requiring a reason for detachments.
- Updated types and services to support new wagon loading/unloading features and booking wagon retrieval.
2026-08-28 07:33:28 +00:00
Abubeker Yasin
bf2a6e827b Update index.ts 2026-08-28 10:02:48 +03:00
Marshal
8b8870e85e fix issue 2026-08-27 20:58:44 +00:00
Roba Boru
e00a1145b2 Merge branch 'main' of https://github.com/Tria-plc/edr-platform into feature/group-booking 2026-08-27 22:56:30 +03:00
Roba Boru
58be74bc29 Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into feature/group-booking 2026-08-27 22:55:42 +03:00
Roba Boru
1603ff8211 Update group booking and package 2026-08-27 22:54:59 +03:00
Nathnael Wondisha
649a3c226c Merge pull request #1433 from Tria-plc/freight/nati-2
Freight/nati 2
2026-08-27 15:59:22 +03:00
Nathnael
7d9d1fb518 fix: invoice filter 2026-08-27 12:40:03 +00:00
Nathnael
63bd9197c5 fix: add platofmr transactions to the invoice pdf 2026-08-27 11:56:10 +00:00
marshal
8bd8955ac5 Merge pull request #1432 from Tria-plc/staging
Staging
2026-08-27 14:24:54 +03:00
marshal
09ae1276c9 Merge pull request #1431 from Tria-plc/dev
dev
2026-08-27 14:18:04 +03:00
marshal
041c165d6c Merge pull request #1430 from Tria-plc/freight_feature/usermanagement
feat: simplify dispatch logic by removing manual loading checks and u…
2026-08-27 14:17:27 +03:00
Marshal
1626903192 feat: simplify dispatch logic by removing manual loading checks and updating related comments 2026-08-27 11:16:43 +00:00
Nathnael Wondisha
81334258d3 Merge pull request #1429 from Tria-plc/freight/nati-2
Freight/nati 2
2026-08-27 12:40:16 +03:00
Nathnael
3af3017b86 feat(backoffice): add an Account tab with the customer's portal logins
The detail page showed the company's business contact details but not the
credentials anyone actually signs in with, and the two drift apart
routinely — so "the customer says they can't log in" was unanswerable
from this screen.

Adds `GET /backoffice/customers/:companyId/accounts`, joining each
external profile to its IAM account, primary contact first. Deliberately
not filtered to active accounts: a suspended or never-activated login is
exactly the case being looked into. The user query selects columns
explicitly — the entity's relations include credentials and sessions, and
this response reaches a browser.

Rendered as cards rather than a table: it is a handful of rows of
mostly-optional fields, which a table renders as a field of dashes.
"Password never set" is called out on its own, being the usual answer to
"they never got in", and a profile whose IAM user is gone reads as a red
fault rather than an inactive status.
2026-08-27 09:30:24 +00:00
Nathnael
f8e8897f5c feat(backoffice): search customers by trade name and licence, filter by role
Staff search with whatever is in front of them. Company name, TIN, email
and profile reference already matched; a TIN's licence number and the
trade name of the business a role operates as did not, which is most of
what appears on a customer's own paperwork.

Adds a Role filter alongside it. Distinct from the existing Type pill:
that is the company's own kind, this asks "who does X?" — one `customer`
company routinely holds importer and exporter at once.

Both are EXISTS subqueries rather than constraints on the joined
`companyProfiles` alias. Filtering the join would drop the company's
other profiles from the loaded entity, so an importer-and-exporter would
render as importer-only.
2026-08-27 09:30:12 +00:00
Nathnael
a48d77aff8 feat(backoffice): show each role's eTrade business on the customer detail page
The reviewer approving a role had no way to see which business it claims
to operate as, so there was nothing to check the uploaded licence
document against. The Role profiles table now carries a column with the
trade name, the licensed activity (does it actually cover this role?),
the licence number (the only unambiguous handle — trade names repeat
across a TIN's licences) and the renewal date.

A role with nothing attached reads as a yellow "Not attached" rather than
a blank: it is a review finding. Yellow, not red, because a co-operative
or investment-licence company legitimately has none.

Two fixes alongside it:

- The profile reference was already rendered but is minted only on
  approval, so every pending role drew an empty line. It now says so.
- `TableCard` gained an optional header section, so padding sits per
  section and the table runs edge to edge. The header stays outside the
  scroll region — inside, a title slides away from its own table.
2026-08-27 09:29:41 +00:00
Nathnael
ab5e7e6db6 feat(billing): show the buyer's trade name on invoice documents
An invoice is billed to one company profile, and that profile's eTrade
licence is usually a different business from the one the company
registered under — so the buyer's name alone does not say which business
was billed.

Both document paths gain a row: the shared invoice/receipt model reads it
off the already-loaded `companyProfile` relation, and the warehouse fee
invoice joins `company_profiles` through the booking.

`sameCompanyName` suppresses the row when it merely repeats the buyer
name, which is the common case. It compares loosely because eTrade spells
one legal suffix three ways (PLC / P L C / PRIVATE LIMITED COMPANY) and
pads names with double spaces; it decides whether a row is worth printing
and nothing else.

The EIMS buyer `LegalName` is deliberately untouched — a MoR filing
carries the registered entity, same rule as the seller side.
2026-08-27 09:29:25 +00:00
Nathnael
604710bd25 feat(companies): attach an eTrade business to each company profile
A TIN holds many business licences split by activity — export of coffee,
freight forwarding, import of vehicles — but the company picked one for
its whole record, so every operational role shared it. Each profile now
names the business it actually operates as.

Stored on `company_profiles.etrade_business` as a snapshot (licence
number, trade name, activity, renewal) rather than a bare licence number,
so the portal and backoffice can show it without an eTrade round-trip —
that API is slow, serves a broken TLS chain and is regularly down. Not
unique: one business may legitimately back several roles.

The licence number is a client input, so it is never stored as sent —
`ETradeService.findBusinessOption` looks it up under the company's own
TIN and persists eTrade's record, which makes another company's licence
simply unfindable.

Choosing one is required wherever the customer adds a role with a TIN
already on file. The onboarding wizard is the exception by necessity: it
picks roles on its first step, before a TIN exists, so there is nothing
to choose from yet. There it is enforced through
`getOnboardingRequirements` instead — an unattached role is reported
outstanding and blocks submission — and the picker sits on the documents
step beside that role's licence upload.

Lifted entirely for a co-operative or investment-licence company: eTrade
holds no record for its TIN, so the requirement would be unsatisfiable.
2026-08-27 09:29:15 +00:00
Nathnael
1f5e6296b1 feat(etrade): take the selected licence's trade name as the company name
A TIN routinely trades under a name that is not its registered one, and
holds several licences with different trade names — of 58 TINs checked
against eTrade, 8 had at least one licence whose trade name differs from
the registered `BusinessName`, one of them across three licences.

`extractRegistrationData` now resolves `companyName` from the selected
licence's `TradeName`, falling back to `BusinessName` (16 of 309 licences
carry a blank trade name, so the fallback is load-bearing).

EIMS is pinned back to `BusinessName` for the seller's `LegalName`: an
invoice is a MoR tax filing and must carry the legal entity, not the
trade name. It is the only other caller.
2026-08-27 09:28:52 +00:00
Abubeker Yasin
689fc11355 Merge pull request #1428 from Tria-plc/alpha
feat(auth): stage passenger sign-in on a single identifier field
2026-08-27 11:41:48 +03:00
Abubeker Yasin
979053ea4f feat(auth): stage passenger sign-in on a single identifier field 2026-08-27 11:37:54 +03:00
marshal
14484d9d2a Merge pull request #1427 from Tria-plc/staging
Staging
2026-08-27 10:55:35 +03:00
marshal
29a0dc0db5 Merge pull request #1426 from Tria-plc/dev
dev
2026-08-27 10:55:02 +03:00
marshal
93cd3fc93f Merge pull request #1425 from Tria-plc/freight_feature/usermanagement
tracking ui
2026-08-27 10:49:18 +03:00
Marshal
065bdd0189 tracking ui 2026-08-27 07:45:32 +00:00
Nathnael Wondisha
edbe95a2cb Merge pull request #1424 from Tria-plc/dev
dev
2026-08-27 08:32:48 +03:00
Nathnael Wondisha
2cd1cea001 Merge pull request #1423 from Tria-plc/freight/nati-2
Freight/nati 2
2026-08-27 08:27:47 +03:00
Nathnael Wondisha
a3b29b2792 Merge pull request #1422 from Tria-plc/freight_feature/usermanagement
feat: refine loading window validation and improve error messaging fo…
2026-08-27 08:26:44 +03:00
Nathnael
4abb2af880 Merge branch 'dev' into freight/nati-2 2026-08-27 05:25:43 +00:00
Nathnael
3a7e16a746 fix(reports): stop finance reports dropping general-contract revenue
Every revenue report excluded invoices whose booking carries
contract_kind = 'GENERAL', on the premise that such a booking is an
umbrella contract row paid once and drawn down by many orders, so
counting it alongside those orders would double-count.

That premise does not hold. On the dev database all 169 GENERAL
bookings are real shipments with origin and destination yards, warehouse
receipts and their own invoices; there is no umbrella invoice to
double-count, and no invoice source of that kind exists at all. The
predicate simply deleted 119 invoices and ETB 164.6M of billed revenue
from every Finance report, which is why revenue-by-customer reported
ETB 39.4M collected while /billing/invoices/summary reported ETB 140.1M
- a gap of exactly ETB 100,741,976, the paid total of the 80 PAID
invoices the predicate hid.

Removing it from the shared revenue and invoice ledgers brings the
reports back in line with billing (ETB 210.6M billed, ETB 140.1M paid,
163 invoices), and removing it from the overview repository restores the
same bookings to the operational KPIs.

Also fixes what that exposed in the reports that build their own query:

- GATEWAY_PAID read the booking's whole gateway total onto every invoice
  sharing that booking. With 25 booking ids backing 58 invoices, the
  reconciliation report claimed ETB 113.2M of receipts against ETB 43.6M
  of settlement and showed ~ETB 89.4M of variance that does not exist.
  Receipts are now apportioned across an invoice's siblings by settled
  share, so the gateway column sums to the payments table and total
  variance is the real ETB 13.1M of manual settlements.
- Aging Receivables joined companies with an INNER JOIN, dropping
  shipping-line-billed arrears, and summed both currencies under a
  hardcoded ETB label. Both payer joins are now LEFT and the report
  takes a currency filter.
- Invoicing Pipeline summed ETB and USD invoices into one ETB total and
  applied no trade-direction scope, unlike every other Finance report.
  Both are now applied.

Verified against the shared dev database: every new statement passes
EXPLAIN, and the report totals reconcile with billing and with
freight.payments. Type-check and the reports and overview suites pass;
the five failures in billing.service.spec.ts are pre-existing on this
branch and untouched by this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 05:22:37 +00:00
Marshal
d9017df730 feat: refine loading window validation and improve error messaging for dispatching 2026-08-27 03:26:44 +00:00
marshal
a1a59f0f7c Merge pull request #1421 from Tria-plc/dev
dev
2026-08-27 05:56:19 +03:00
marshal
3f353e52b8 Merge pull request #1420 from Tria-plc/freight_feature/usermanagement
feat: enhance station work logging with user display names and improv…
2026-08-27 05:30:58 +03:00
Marshal
6ec3c8c5e0 feat: enhance station work logging with user display names and improve loading/unloading logic 2026-08-27 02:29:50 +00:00