Payment summary/all moved off bookings:view onto payments:view, the
booking list now requires bookings:view for staff instead of accepting
any employee, and wagon reads require wagons:view or fleet:view rather
than bare authentication.
Gates the previously open support-agent, procurement, compliance,
facilities, list-users and trade-access controllers, separates customer
from staff routes across bookings, contracts, companies, billing,
warehouses, files and train scheduling, and moves billing, overview,
reports and the settings controllers onto their own keys instead of the
blanket admin key. Drops the demo-permissions module and the untested
notification test route.
feat(bookings): add event handlers for booking invoice payment processing
fix(bookings): include PAYMENT_VERIFICATION_IN_PROGRESS status in queries
fix(train-scheduling): update status checks to include PAYMENT_VERIFICATION_IN_PROGRESS
feat(notifier): notify customers when a train is cancelled
CAC Bank's initiate SMSes an OTP before it answers and routinely takes more
than the 10s this client allowed, so every CAC charge came back as a 502
while the bank was still working — and if the bank had answered, the payer
would have been texted a code for an intent the freight side never recorded.
Matches the passenger API, which already budgets 60s and reads the same
PAYMENT_API_HTTP_TIMEOUT_MS override.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The confirm flow settles money on a provider with no webhook, so the two
places it can go wrong are worth pinning: that the OTP is forwarded against
the GATEWAY intent id (not the local projection id), and that a rejected
code leaves the intent open instead of failing the payment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CAC Bank is an OTP debit with no redirect and no webhook: initiate SMSes a
code to the payer's mobile, and the charge only settles when that code is
confirmed. The payment service already spoke it (passenger uses it); the
freight side had the enum values but none of the flow.
API:
- PaymentClientService.confirmOtp forwards the code to
POST /payments/intents/:id/confirm, mapping 400/404 to BadRequest so a
mistyped code stays retryable instead of surfacing as a gateway failure.
- PaymentService.confirmOtp is keyed by the LOCAL intent id (the invoice's
paymentId) rather than the domain reference, so the right invoice settles
when several share a booking. On success billing settles the invoice.
- payInvoice rejects CAC_BANK without payerAccount before calling the
gateway, and no longer runs the demo auto-settle for a COLLECT_OTP intent
(it is not paid until the payer confirms).
- POST /billing/my-invoices/:id/confirm — ownership-checked, and since
warehouse fee invoices are central invoices it covers those too.
Portal:
- useInvoicePayment owns the whole flow (initiate, redirect-or-OTP, confirm)
and replaces the five near-identical pay mutations at the call sites.
- PaymentMethodModal gains the CAC Bank option, the payer mobile field, and
the OTP step. Click-outside is disabled there so a stray click cannot drop
the payer out of a live OTP window.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace the fixed 5-minute grace with a settlement check at expiry:
expire() calls the payment API's reconcile endpoint — paid intents are
kept and allocated via payment.succeeded, unverifiable results defer
expiry to the next tick, only verifiably unpaid holds expire.
The customers:* keys were seeded and present in the backoffice constants but
enforced nowhere except reset-password. Customer CRUD sat behind the coarse
edr_freight_app:admin umbrella, and every company read endpoint was unguarded.
Two routes could not be gated on the route alone, because the authority they
need depends on the request BODY, not the path:
- PATCH /companies/:id carries `status` (UpdateCompanyDto extends
PartialType(CreateCompanyDto)), so it both edits fields and blacklists.
- PATCH /company-profiles/:profileId/status is approve, reject, suspend and
blacklist on one route.
Both now take a one-of route guard and assert per-status against a shared
STATUS_PERM map: approving/reactivating needs customers:verify, suspending or
blacklisting needs customers:deactivate. PATCH /companies/:id additionally
requires customers:update when any non-status field is present, so a caller
holding only deactivate cannot rename a company. The backoffice mirrors the
same map so no button is offered that the server would reject.
GET /companies/:companyId/documents is left authenticated-only with the split
in the handler: it is dual-audience. The portal reads its own documents during
onboarding, and the contract-request detail page (gated on contracts:view)
reads the applicant's. Gating it on customers:view alone would have 403'd
customers on their own documents and blanked the contract reviewer's panel.
The two by-company customer-view reads take a one-of guard for the same reason
— otherwise a staffer granted only customers:view gets a detail page whose tabs
403 individually.
Frontend: the customers routes were sidebar-filtered but not wrapped in
RequirePermission, so direct URL navigation rendered them for anyone.
Verified: freight-api type-check clean; backoffice type-check unchanged from
HEAD (pre-existing errors only); 25 tests pass across the companies and
freight-permission suites. Not exercised against a running API.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>