From ea6eccbf098003d45907ea77f1c6c043d51407e8 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Tue, 11 Aug 2026 09:26:23 +0300 Subject: [PATCH 1/3] feat(upload): increase document upload size limits to 50MB across the application --- .../src/common/document-upload.options.ts | 30 +++++++ apps/edr-freight-api/src/main.ts | 12 ++- ...0000000000-RaiseDocumentUploadSizeLimit.ts | 44 ++++++++++ .../modules/companies/companies.controller.ts | 3 +- .../entities/file-upload-field.entity.ts | 2 +- .../poa-delegation.constants.ts | 2 +- .../src/seed/file-upload-settings.seeder.ts | 24 +++--- docs/uploads.md | 86 +++++++++++++++++++ infrastructure/nginx/spa.conf | 5 ++ 9 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 apps/edr-freight-api/src/common/document-upload.options.ts create mode 100644 apps/edr-freight-api/src/migrations/3380000000000-RaiseDocumentUploadSizeLimit.ts create mode 100644 docs/uploads.md diff --git a/apps/edr-freight-api/src/common/document-upload.options.ts b/apps/edr-freight-api/src/common/document-upload.options.ts new file mode 100644 index 000000000..736d8099b --- /dev/null +++ b/apps/edr-freight-api/src/common/document-upload.options.ts @@ -0,0 +1,30 @@ +import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface"; + +/** + * Ceiling for a single uploaded document, in bytes. + * + * Mirrors the 50MB `max_size_mb` the file-upload settings hand the portal, so + * the client-side gate and the server-side cap agree. Raising this alone is not + * enough to accept a 50MB upload: the reverse proxy in front of the API applies + * its own `client_max_body_size`, and nginx's 1MB default rejects the request + * with a 413 before it ever reaches Nest (see docs/uploads.md). + */ +export const DOCUMENT_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; + +/** Upper bound on parts in one multipart document post. */ +export const DOCUMENT_UPLOAD_MAX_FILES = 20; + +/** + * Multer caps for the document upload routes. + * + * Without an explicit `fileSize`, multer's default is unlimited and every byte + * is buffered in memory, so an oversized post is absorbed in full before + * anything can reject it. With the limit set, multer stops reading the socket + * at the ceiling instead. + */ +export const documentUploadMulterOptions: MulterOptions = { + limits: { + fileSize: DOCUMENT_UPLOAD_MAX_BYTES, + files: DOCUMENT_UPLOAD_MAX_FILES, + }, +}; diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index c9a718f5d..f26cd19e7 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -16,11 +16,17 @@ import { AppModule } from "./app.module"; /** * JSON body ceiling. Signing posts the signature AND the company stamp as - * base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is - * ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp + * base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 50MB asset is + * ~67MB on the wire. Express defaults to 100kb, which rejected any real stamp * image with a 413 "request entity too large". + * + * Sized to clear the 50MB per-document ceiling + * (`DOCUMENT_UPLOAD_MAX_BYTES`) after base64 inflation, with room for the + * surrounding JSON. Note that the reverse proxy applies its own + * `client_max_body_size` and rejects oversized bodies before Nest sees them — + * raising this alone does not lift the limit end to end (see docs/uploads.md). */ -const JSON_BODY_LIMIT = "20mb"; +const JSON_BODY_LIMIT = "100mb"; /** * Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as diff --git a/apps/edr-freight-api/src/migrations/3380000000000-RaiseDocumentUploadSizeLimit.ts b/apps/edr-freight-api/src/migrations/3380000000000-RaiseDocumentUploadSizeLimit.ts new file mode 100644 index 000000000..99d949e2e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3380000000000-RaiseDocumentUploadSizeLimit.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Raises the per-field document ceiling from 10MB to 50MB. + * + * `max_size_mb` is what the portal enforces client-side (SmartFileInput blocks + * the file and shows "File size exceeds the limit of NMB"), so the seeded 10 + * was the visible limit for every existing form even after the server-side caps + * were lifted. The seeder only writes these rows on first insert, so deployed + * environments keep their old value until this runs. + * + * Only rows still sitting at the old default are touched — a field an admin has + * deliberately tuned to something else keeps that value. + */ +export class RaiseDocumentUploadSizeLimit3380000000000 + implements MigrationInterface +{ + name = "RaiseDocumentUploadSizeLimit3380000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.file_upload_fields + ALTER COLUMN max_size_mb SET DEFAULT 50 + `); + await queryRunner.query(` + UPDATE freight.file_upload_fields + SET max_size_mb = 50 + WHERE max_size_mb = 10 + `); + } + + /** + * Restores the column default only. The old per-row values are not + * recoverable (10 and an admin-chosen 10 are indistinguishable after `up`), + * and shrinking a customer's limit back down would reject documents they have + * already uploaded, so the rows are deliberately left at 50. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.file_upload_fields + ALTER COLUMN max_size_mb SET DEFAULT 10 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index de7a01484..1b14845b2 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -20,6 +20,7 @@ import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff, MixedAudience, PortalCustomer } from "../../common/booking-guards"; +import { documentUploadMulterOptions } from "../../common/document-upload.options"; import { assertFreightPermission, hasFreightPermission, @@ -726,7 +727,7 @@ export class CompaniesController { @Post(":companyId/documents") @MixedAudience(FREIGHT_PERMS.customers.update) - @UseInterceptors(AnyFilesInterceptor()) + @UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions)) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Upload documents for a company (onboarding)" }) async uploadDocuments( diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts index e76c5716b..097c1ec39 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts @@ -50,7 +50,7 @@ export class FileUploadField extends BaseEntity { }) allowedExtensions!: string[]; - @Column({ name: "max_size_mb", type: "integer", default: 10 }) + @Column({ name: "max_size_mb", type: "integer", default: 50 }) maxSizeMb!: number; @Column({ name: "display_order", type: "integer", default: 0 }) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts index 9085cc018..7f8a34175 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts @@ -46,7 +46,7 @@ export function poaDelegationField(displayOrder: number): FileUploadField { isMultiple: false, maxFiles: 1, allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 10, + maxSizeMb: 50, displayOrder, } as FileUploadField; } diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 7bcae753b..512229706 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -37,7 +37,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 1, }, { @@ -49,7 +49,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 2, }, { @@ -60,7 +60,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 3, }, poaDelegationDefault(4), @@ -76,7 +76,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 1, }, { @@ -87,7 +87,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 2, }, { @@ -98,7 +98,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 3, }, { @@ -109,7 +109,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 4, }, poaDelegationDefault(5), @@ -127,7 +127,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // isMultiple: false, // maxFiles: 1, // allowedExtensions: DOC_EXTENSIONS, -// maxSizeMb: 10, +// maxSizeMb: 50, // displayOrder: 1, // }, // { @@ -138,7 +138,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // isMultiple: false, // maxFiles: 1, // allowedExtensions: DOC_EXTENSIONS, -// maxSizeMb: 10, +// maxSizeMb: 50, // displayOrder: 2, // }, // { @@ -149,7 +149,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // isMultiple: false, // maxFiles: 1, // allowedExtensions: DOC_EXTENSIONS, -// maxSizeMb: 10, +// maxSizeMb: 50, // displayOrder: 3, // }, // ]; @@ -232,7 +232,7 @@ function clearanceField( isMultiple: false, maxFiles: 1, allowedExtensions: opts?.extensions ?? DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder, }; } @@ -556,7 +556,7 @@ const DRIVER_DOCUMENT_FIELDS: OnboardingField[] = [ isMultiple: true, maxFiles: 20, allowedExtensions: ["pdf", "jpg", "jpeg", "png", "doc", "docx"], - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 1, }, ]; diff --git a/docs/uploads.md b/docs/uploads.md new file mode 100644 index 000000000..a41763d89 --- /dev/null +++ b/docs/uploads.md @@ -0,0 +1,86 @@ +# Upload size limits + +A document upload passes through four independent ceilings. The **smallest one +wins**, so raising a limit in application code changes nothing while a lower +limit sits in front of it. + +| # | Layer | Limit | Where it lives | +| - | ----- | ----- | -------------- | +| 1 | Reverse proxy (`client_max_body_size`) | **50m** | nginx config on the API host — **not in this repo** for deployed environments | +| 2 | Express JSON/urlencoded body | `JSON_BODY_LIMIT` = 80mb | `apps/edr-freight-api/src/main.ts` | +| 3 | Multer multipart (`fileSize`) | `DOCUMENT_UPLOAD_MAX_BYTES` = 50MB | `apps/edr-freight-api/src/common/document-upload.options.ts` | +| 4 | Per-field portal gate (`max_size_mb`) | 50 | `freight.file_upload_fields`, seeded by `file-upload-settings.seeder.ts` | + +Layer 4 is the only one the customer sees before uploading — `SmartFileInput` +blocks the file client-side with "File size exceeds the limit of NMB". Layers +1–3 produce a failed request after the fact. + +## The nginx layer is the one that bites + +nginx defaults `client_max_body_size` to **1m** and answers anything larger with +its own HTML error page: + +``` +HTTP/1.1 413 Request Entity Too Large +Server: nginx +Content-Type: text/html +``` + +Two tells that a 413 came from the proxy rather than the API: + +- the body is nginx's HTML page, not the API's JSON envelope, and +- `curl -w '%{size_upload}'` reports **0** — nginx rejects on the `Content-Length` + header, so the body is never transmitted. + +This is also why the failure looks like a CORS error in the browser: nginx's +error response carries no `Access-Control-Allow-Origin` header. + +### Applying it + +Add to the `server` (or `location`) block fronting the API and reload: + +```nginx +server { + server_name edrfreightapi-staging.edrsc.com; + + client_max_body_size 50m; + + location / { + proxy_pass http://freight-api:3001; + # Large uploads stream for a while; the default 60s read timeout can + # cut off a slow client mid-body. + proxy_read_timeout 300s; + proxy_request_buffering off; + } +} +``` + +```bash +nginx -t && nginx -s reload # -t first: a bad config that reloads takes the site down +``` + +On Kubernetes ingress-nginx this is an annotation on the Ingress instead: + +```yaml +nginx.ingress.kubernetes.io/proxy-body-size: 50m +``` + +Note that `client_max_body_size 0` disables the check entirely — do not use it. +An unbounded body is a denial-of-service vector, and layer 3 buffers uploads in +memory. + +## Verifying end to end + +512KB should pass the proxy and reach the API; 50MB should too. A `401` here is +a *success* for this purpose — it means the request got past nginx to the API's +auth layer. + +```bash +head -c 52428800 /dev/urandom > big.bin +curl -s -o /dev/null -w 'HTTP %{http_code} uploaded %{size_upload}\n' \ + -X POST "https://edrfreightapi-staging.edrsc.com/api/companies//documents" \ + -F "test=@big.bin" +``` + +- `413` with `uploaded 0` → the proxy is still capped; layer 1 was not applied. +- `401`/`200` with the full byte count → the body made it through. diff --git a/infrastructure/nginx/spa.conf b/infrastructure/nginx/spa.conf index 83629210c..0cc9b1de0 100644 --- a/infrastructure/nginx/spa.conf +++ b/infrastructure/nginx/spa.conf @@ -11,6 +11,11 @@ server { add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + # nginx defaults to 1m and answers anything larger with its own HTML 413 + # before the request reaches an upstream. Kept in step with the 50MB + # per-document ceiling the API enforces (DOCUMENT_UPLOAD_MAX_BYTES). + client_max_body_size 50m; + gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/wasm; gzip_min_length 1024; From dc2078dd2836dd51875998b16df72108b8f7b685 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 11 Aug 2026 11:19:39 +0300 Subject: [PATCH 2/3] fix: ( payments ) stop reconcile-before-cancel deferring bookings forever --- .../payments/payment-client.service.ts | 16 ++- .../src/modules/payments/payments.service.ts | 19 ++- .../src/modules/tasks/tasks.service.ts | 59 ++++++-- .../intents/intents.service.cbe-bill.spec.ts | 126 ++++++++++++++++++ .../src/modules/intents/intents.service.ts | 69 ++++++++-- 5 files changed, 258 insertions(+), 31 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts index e723dc8ce..bf6c86fea 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -22,6 +22,13 @@ export interface PaymentDiagnostic { provider: ProviderStatus | null; } +/** + * Why a settlement check came back `unverifiable` (mirrors the payment service's + * ReconcileUnverifiableReason). `IN_FLIGHT` means money is actually moving and must be waited out; + * `PROVIDER_ERROR` can be a permanently unreachable gateway, which a sweep may eventually give up on. + */ +export type SettlementUnverifiableReason = "IN_FLIGHT" | "PROVIDER_ERROR"; + /** Settlement check from POST /payments/reconcile (verify-before-cancel). */ export interface SettlementResult { /** At least one intent for the order is paid (incl. a late capture just registered). */ @@ -31,6 +38,8 @@ export interface SettlementResult { /** Settlement could not be confirmed — a provider query errored, a payment is in flight, OR the * payment service was unreachable. The caller MUST NOT cancel the order. */ unverifiable: boolean; + /** Set whenever `unverifiable` — which of the two causes applies. */ + reason?: SettlementUnverifiableReason; } /** @@ -117,7 +126,9 @@ export class PaymentClientService { this.logger.warn( `reconcile ${referenceType}/${referenceId} failed: ${message}; treating as unverifiable (will not cancel)`, ); - return { paid: false, unverifiable: true }; + // The payment service itself is unreachable — indistinguishable from a dead gateway, and + // like one it may never recover, so it is a PROVIDER_ERROR (give-up-able), not IN_FLIGHT. + return { paid: false, unverifiable: true, reason: "PROVIDER_ERROR" }; } } @@ -173,9 +184,6 @@ export class PaymentClientService { body?: unknown, ): Promise { const url = `${this.baseUrl}${path}`; - this.logger.log("====================================================================="); - this.logger.log(`URL ${url}`); - this.logger.log("====================================================================="); try { const response = await firstValueFrom( this.http.request({ diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 137df45ee..5cd483830 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -39,6 +39,7 @@ import { import { PaymentClientService, PaymentDiagnostic, + SettlementUnverifiableReason, } from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { AuditService } from "../../common/audit.service"; @@ -1116,10 +1117,17 @@ export class PaymentsService { * - not paid → verified unpaid; a cancellation caller may proceed. * - unverifiable (provider query errored, in-flight, or payment service unreachable) → a * cancellation caller must NOT cancel this cycle; defer and retry later. + * + * When unverifiable, `reason` says WHY, and the two are not interchangeable: `IN_FLIGHT` is a + * payment actually moving (defer forever — this is the case the guard exists for), while + * `PROVIDER_ERROR` may be a gateway that never comes back, which a sweep is allowed to give up + * on after a grace window rather than retry once a minute in perpetuity. */ - async reconcileAndConfirmIfPaid( - bookingId: string, - ): Promise<{ paid: boolean; verified: boolean }> { + async reconcileAndConfirmIfPaid(bookingId: string): Promise<{ + paid: boolean; + verified: boolean; + reason?: SettlementUnverifiableReason; + }> { const current = await this.prisma.booking.findUnique({ where: { id: bookingId }, select: { status: true }, @@ -1134,10 +1142,11 @@ export class PaymentsService { ); if (settlement.unverifiable) { + const reason = settlement.reason ?? "PROVIDER_ERROR"; this.logger.warn( - `reconcile-before-cancel: settlement UNVERIFIABLE for booking ${bookingId} — not cancelling`, + `reconcile-before-cancel: settlement UNVERIFIABLE (${reason}) for booking ${bookingId} — not cancelling`, ); - return { paid: false, verified: false }; + return { paid: false, verified: false, reason }; } if (settlement.paid) { diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 284eb61c0..fe4207a50 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -14,6 +14,15 @@ const AUDIT_LOG_RETENTION_DAYS = 365; const WEBHOOK_EVENT_RETENTION_DAYS = 90; const GATE_LOG_RETENTION_DAYS = 180; +// How long past its payment deadline a booking may sit undecided because the GATEWAY cannot be +// reached (PROVIDER_ERROR) before the sweep stops deferring and cancels anyway. Without a bound, +// a permanently unreachable provider pins a booking as PENDING_PAYMENT forever — its seats stay +// held and the sweep re-queries it once a minute, indefinitely. NEVER applied to an IN_FLIGHT +// settlement: money that is actually moving is waited out no matter how long it takes. +// Raise this in production — a 10-minute gateway outage should not mass-cancel bookings that may +// well be paid (a late payment then lands on a CANCELLED booking and needs a manual refund). +const RECONCILE_GRACE_MINUTES = Number(process.env.RECONCILE_GRACE_MINUTES) || 5; + function fmtTime(d: Date): string { return d.toLocaleTimeString('en-GB', { hour: '2-digit', @@ -325,15 +334,38 @@ export class TasksService { // Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded // event may have been lost (RabbitMQ down) or arrived late, leaving a paid booking stuck // PENDING_PAYMENT. Ask the payment service over HTTP; it confirms the booking synchronously - // if paid. Only proceed to cancel when settlement is VERIFIED unpaid. + // if paid. Cancel only on a VERIFIED-unpaid settlement — or, past the grace window below, + // on a settlement the gateway simply refuses to answer for. const settlement = await paymentsService.reconcileAndConfirmIfPaid(booking.id); - if (settlement.paid || !settlement.verified) { - this.logger.log( - `Skip auto-cancel ${booking.bookingRef}: ${settlement.paid ? 'PAID → confirmed' : 'unverifiable → deferred'}`, - ); + if (settlement.paid) { + this.logger.log(`Skip auto-cancel ${booking.bookingRef}: PAID → confirmed`); continue; } + // Unverifiable: defer — but not forever. IN_FLIGHT is real money moving, so it is waited + // out indefinitely. A PROVIDER_ERROR (dead gateway, payment service down) is bounded by + // RECONCILE_GRACE_MINUTES past the deadline; beyond that the booking is cancelled on an + // UNVERIFIED settlement, which is recorded explicitly below so finance can chase it. + let unverifiedGiveUp = false; + if (!settlement.verified) { + const graceExpiresAt = new Date( + paymentDeadline.getTime() + RECONCILE_GRACE_MINUTES * 60 * 1000, + ); + if (settlement.reason === 'IN_FLIGHT' || now < graceExpiresAt) { + this.logger.log( + `Skip auto-cancel ${booking.bookingRef}: unverifiable (${settlement.reason ?? 'PROVIDER_ERROR'}) → deferred`, + ); + continue; + } + unverifiedGiveUp = true; + this.logger.error( + `Auto-cancelling ${booking.bookingRef} on an UNVERIFIED settlement — the gateway has ` + + `been unreachable for ${RECONCILE_GRACE_MINUTES}+ min past the deadline. If this ` + + `booking was in fact paid, the payment will land on a CANCELLED booking and needs a ` + + `manual refund.`, + ); + } + // 1a. Release held seats (Journey rows are the occupancy source of truth once paid) await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); @@ -349,15 +381,21 @@ export class TasksService { }); } - // 2. Audit record (no refund — payment was never completed) + // 2. Audit record (no refund — payment was verified never completed, or, on an unverified + // give-up, flagged for review because we could not establish that) await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: 'SYSTEM', - reason: 'Payment not completed before deadline', + reason: unverifiedGiveUp + ? `Payment not completed before deadline; settlement UNVERIFIED — gateway unreachable ` + + `for ${RECONCILE_GRACE_MINUTES}+ min past the deadline. Confirm no payment was taken.` + : 'Payment not completed before deadline', refundAmount: 0, refundMethod: booking.paymentIntent?.method ?? 'NONE', - refundStatus: 'NOT_APPLICABLE', + // An unverified give-up may yet turn out to have been paid, so it is neither + // NOT_APPLICABLE nor a refund actually owed — flag it for a human instead. + refundStatus: unverifiedGiveUp ? 'REVIEW_REQUIRED' : 'NOT_APPLICABLE', }, }).catch(() => null); @@ -380,7 +418,10 @@ export class TasksService { await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); } - this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`); + this.logger.log( + `Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})` + + (unverifiedGiveUp ? ' — UNVERIFIED settlement, review required' : ''), + ); cancelledCount++; } catch (err) { this.logger.error( diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts index 953533d02..e80f3f640 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts @@ -155,4 +155,130 @@ describe("IntentsService CBE_BILL", () => { expect(snapshot.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION); expect(applySpy).not.toHaveBeenCalled(); }); + + /** + * Regression: reconcileReference used to route every non-FAILED intent through + * queryProviderStatus, which THROWS "Unknown provider" for CBE_BILL (no map entry — D5). The + * throw was counted as a provider error, so the check returned `unverifiable` forever and the + * owning app could never auto-cancel the booking: seats stayed held and the sweep re-queried + * the same booking once a minute for days. With no outbound query to make, the stored status + * IS the answer. + */ + describe("reconcileReference (reconcile-before-cancel)", () => { + const cbeIntent = (status: ProviderPaymentStatus) => + ({ + id: "intent-1", + service: PaymentService.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: "booking-1", + merchantOrderId: "PSG-x", + provider: ProviderMethod.CBE_BILL, + status, + amountMinor: 1500, + currency: "ETB", + billReference: "000100000015", + }) as unknown as PaymentIntent; + + it("reports an unpaid CBE_BILL intent as VERIFIED not paid, not unverifiable", async () => { + repository.findAllByReference.mockResolvedValue([ + cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION), + ] as never); + + const result = await service.reconcileReference( + PaymentService.PASSENGER, + PaymentReferenceType.BOOKING, + "booking-1", + ); + + expect(result).toEqual({ paid: false, unverifiable: false }); + expect(result.reason).toBeUndefined(); + }); + + it("still reports a retired-but-settled CBE_BILL intent as paid", async () => { + // The inbound /cbe/payment already flipped it; step 2 of the resolution catches it. + repository.findAllByReference.mockResolvedValue([ + cbeIntent(ProviderPaymentStatus.SUCCEEDED), + ] as never); + + const result = await service.reconcileReference( + PaymentService.PASSENGER, + PaymentReferenceType.BOOKING, + "booking-1", + ); + + expect(result.paid).toBe(true); + expect(result.unverifiable).toBe(false); + }); + + it("does not let an unqueryable sibling mask a real provider error", async () => { + const telebirr = { + ...cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION), + id: "intent-2", + provider: ProviderMethod.TELEBIRR, + } as unknown as PaymentIntent; + providers.set(ProviderMethod.TELEBIRR, { + method: ProviderMethod.TELEBIRR, + queryStatus: jest.fn().mockRejectedValue(new Error("ETIMEDOUT")), + }); + repository.findAllByReference.mockResolvedValue([ + cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION), + telebirr, + ] as never); + + const result = await service.reconcileReference( + PaymentService.PASSENGER, + PaymentReferenceType.BOOKING, + "booking-1", + ); + + expect(result).toEqual({ + paid: false, + unverifiable: true, + reason: "PROVIDER_ERROR", + }); + providers.delete(ProviderMethod.TELEBIRR); + }); + + it("reports IN_FLIGHT ahead of PROVIDER_ERROR so a caller never gives up on moving money", async () => { + const processing = { + ...cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION), + id: "intent-2", + provider: ProviderMethod.TELEBIRR, + } as unknown as PaymentIntent; + const failing = { + ...cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION), + id: "intent-3", + provider: ProviderMethod.WAAFI, + } as unknown as PaymentIntent; + providers.set(ProviderMethod.TELEBIRR, { + method: ProviderMethod.TELEBIRR, + queryStatus: jest + .fn() + .mockResolvedValue({ status: ProviderPaymentStatus.PROCESSING }), + }); + providers.set(ProviderMethod.WAAFI, { + method: ProviderMethod.WAAFI, + queryStatus: jest.fn().mockRejectedValue(new Error("ETIMEDOUT")), + }); + repository.findAllByReference.mockResolvedValue([ + processing, + failing, + ] as never); + repository.findById.mockResolvedValue(processing); + jest + .spyOn(service, "applyProviderResult") + .mockResolvedValue({ alreadyTerminal: false }); + + const result = await service.reconcileReference( + PaymentService.PASSENGER, + PaymentReferenceType.BOOKING, + "booking-1", + ); + + expect(result.unverifiable).toBe(true); + expect(result.reason).toBe("IN_FLIGHT"); + providers.delete(ProviderMethod.TELEBIRR); + providers.delete(ProviderMethod.WAAFI); + }); + }); }); diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 21da0707f..53d4e70bd 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -49,6 +49,14 @@ export interface ProviderResultInput { rawResponse?: Record; } +/** + * Why a settlement check came back `unverifiable`. The two causes are NOT interchangeable: + * `IN_FLIGHT` is money actually moving and must be waited out indefinitely, while + * `PROVIDER_ERROR` can be a permanently unreachable gateway — a caller may eventually give up on + * that one rather than defer forever (see TasksService's reconcile grace window). + */ +export type ReconcileUnverifiableReason = "IN_FLIGHT" | "PROVIDER_ERROR"; + /** Result of {@link IntentsService.reconcileReference} — a settlement check for a domain order. */ export interface ReconcileReferenceResult { /** True when at least one intent for the order is settled (SUCCEEDED, incl. a just-registered late capture). */ @@ -56,10 +64,12 @@ export interface ReconcileReferenceResult { /** Snapshot of the paying intent when `paid`. */ intent?: PaymentIntentSnapshot; /** - * True when we could NOT confirm "not paid": at least one candidate intent's provider status - * query errored, so its settlement is unknown. Callers must treat this as "do not cancel". + * True when we could NOT confirm "not paid": a candidate intent's provider status query errored, + * or a payment is still in flight. Callers must treat this as "do not cancel". */ unverifiable: boolean; + /** Set whenever `unverifiable` — which of the two causes applies. */ + reason?: ReconcileUnverifiableReason; } @Injectable() @@ -486,19 +496,44 @@ export class IntentsService { const candidates = intents.filter( (i) => i.status !== ProviderPaymentStatus.FAILED, ); + + // Inbound-only methods (CBE_BILL) have deliberately no PAYMENT_PROVIDER_MAP entry — plan D5, + // docs/cbe/CBE_IMPLEMENTATION_PLAN.md. There is NO outbound query to make, so their stored + // status is the best truth available and step 2 above already checked it. Counting them as + // provider errors made every CBE_BILL order permanently `unverifiable` and therefore + // impossible to auto-cancel — the caller deferred forever, once a minute, indefinitely. + const queryable = candidates.filter((i) => this.providers.has(i.provider)); + const unqueryable = candidates.length - queryable.length; + if (unqueryable > 0) { + this.logger.log( + `reconcile: ${unqueryable}/${candidates.length} intent(s) for ${referenceType}/${referenceId} ` + + `have no outbound status query (inbound-only provider) — trusting the stored status`, + ); + } + + // Queried in parallel: a booking that accumulated several dead sessions used to serialise one + // 10s provider timeout per intent, so a single stuck order could hold the caller's sweep for + // 30s+. Results are still APPLIED in order, and we still stop at the first settled intent. + const probes = await Promise.all( + queryable.map(async (intent) => { + try { + return { intent, status: await this.queryProviderStatus(intent) }; + } catch (err) { + this.logger.warn( + `reconcile: queryStatus failed for intent ${intent.id} (${intent.merchantOrderId}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return { intent, status: null }; + } + }), + ); + let providerErrors = 0; let inFlight = false; - for (const intent of candidates) { - let status: ProviderStatus; - try { - status = await this.queryProviderStatus(intent); - } catch (err) { + for (const { intent, status } of probes) { + if (!status) { providerErrors++; - this.logger.warn( - `reconcile: queryStatus failed for intent ${intent.id} (${intent.merchantOrderId}): ${ - err instanceof Error ? err.message : String(err) - }`, - ); continue; } @@ -528,7 +563,15 @@ export class IntentsService { } } - return { paid: false, unverifiable: providerErrors > 0 || inFlight }; + // IN_FLIGHT outranks PROVIDER_ERROR: a caller that gives up after N minutes of gateway errors + // must NEVER apply that give-up to an order whose payment is actually moving. + if (inFlight) { + return { paid: false, unverifiable: true, reason: "IN_FLIGHT" }; + } + if (providerErrors > 0) { + return { paid: false, unverifiable: true, reason: "PROVIDER_ERROR" }; + } + return { paid: false, unverifiable: false }; } /** Best-effort live provider status for a merchant order id; never throws (returns null). */ From 07a120af5ecba2772e6312706733df745b9f17c8 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Tue, 11 Aug 2026 11:48:16 +0300 Subject: [PATCH 3/3] feat(permissions): add granular permissions for train management actions feat(train-builder): update permissions checks for train actions in UI feat(contracts): enhance contract view and shipment request pages with new features test(shipment-preview): add tests for customs clearing logic in booking preview style(contract-sign-bar): create CSS for fixed sign bar layout --- .../contracts/contract-booking.service.ts | 5 + .../contracts/shipment-preview-parity.spec.ts | 123 ++++++++++++++++++ .../trains/train-builder.controller.ts | 14 +- .../src/seed/freight-permissions.registry.ts | 31 +++++ .../backoffice/src/lib/permissions.ts | 5 + .../trainBuilder/TrainBuilderDetailPage.tsx | 94 ++++++------- .../src/pages/contracts/ContractViewPage.tsx | 33 ++--- .../contracts/NewShipmentRequestPage.tsx | 24 ++-- .../src/pages/contracts/contract-sign-bar.css | 11 ++ 9 files changed, 267 insertions(+), 73 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 441544b39..d5989f2ac 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1974,6 +1974,11 @@ export class ContractBookingService { isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), isGovernment: contract.isGovernment, + // The clearance fee is gated on this flag in BookingPricingService, and + // createUnderContract copies it off the contract. Omitting it here priced + // the preview WITHOUT the customs line the created booking is then billed + // — the customer confirmed one total and got invoiced a larger one. + customsClearingEnabled: contract.customsClearingEnabled, shippingLineId: null, contractRouteId: route?.id ?? null, originYardId: route?.originYardId ?? null, diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts new file mode 100644 index 000000000..4152ffb30 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts @@ -0,0 +1,123 @@ +import { ContractBookingService } from './contract-booking.service'; +import type { Contract } from './entities/contract.entity'; +import type { Booking } from '../bookings/entities/booking.entity'; +import type { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; + +/** + * The price the customer confirms in the modal comes from validateShipment, + * which prices an UNSAVED twin of the booking createUnderContract will write. + * Any contract field that reaches the pricing service must be copied onto that + * twin — a field left off doesn't fail loudly, it silently drops whole charge + * lines from the quote while the created booking is still billed for them. + * + * The regression this locks: `customsClearingEnabled` was missing, so + * BookingPricingService's `if (booking.customsClearingEnabled)` gate never + * opened in the preview. Container bookings quoted rail freight alone, then + * invoiced rail + customs clearance. + */ +describe('shipment preview / created booking parity', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c1', + freightType: 'CONTAINER', + tradeDirection: 'EXPORT', + paymentCurrency: 'ETB', + serviceTypeId: 'svc1', + customsClearingEnabled: true, + equipmentReturn: 'NO_RETURN', + isHazardous: false, + isReefer: false, + isGovernment: false, + cargoScope: [], + firstMilePickupAddress: null, + lastMileDeliveryAddress: null, + ...over, + }) as Contract; + + /** + * Run validateShipment against stubbed collaborators and hand back the + * booking the pricing service was actually asked to price. + */ + const previewBookingFor = async (c: Contract): Promise => { + let priced: Booking | null = null; + + const svc = { + contractsRepository: { findByIdWithRelations: async () => c }, + bookingPricingService: { + computePriceForBooking: async (b: Booking) => { + priced = b; + return { + lineItems: [], + totalAmount: 0, + currency: 'ETB', + overweightLines: [], + hardBlocked: [], + }; + }, + }, + ruleEngineService: { capacityViolations: async () => [] }, + resolveRoute: async () => null, + resolveShipmentCurrency: () => 'ETB', + resolveCargoTypeId: () => null, + resolveShipmentHandlingFlag: () => false, + resolveShipmentEquipmentReturn: () => c.equipmentReturn, + resolveBulkTons: () => 0, + resolveBulkWeightTons: () => 0, + resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }), + handlingCounts: () => ({ + hazardousQuantity: 0, + reeferQuantity: 0, + returnQuantity: 0, + }), + max20ftPairDiffTons: async () => 2, + findContainerClashesOnTrain: async () => [], + }; + + const dto = { + containers: [ + { + containerSize: '40ft', + quantity: 2, + units: [{ vgmTons: 10 }, { vgmTons: 10 }], + }, + ], + } as unknown as CreateBookingUnderContractDto; + + await ( + ContractBookingService.prototype as unknown as { + validateShipment: ( + this: unknown, + id: string, + dto: CreateBookingUnderContractDto, + ) => Promise; + } + ).validateShipment.call(svc, 'c1', dto); + + if (!priced) throw new Error('pricing service was never called'); + return priced; + }; + + it('prices the preview with customs clearing on when the contract clears', async () => { + // Without this the clearance fee is quoted as 0 and billed in full later. + const booking = await previewBookingFor(contract()); + expect(booking.customsClearingEnabled).toBe(true); + }); + + it('leaves customs clearing off when the contract does not clear', async () => { + const booking = await previewBookingFor( + contract({ customsClearingEnabled: false }), + ); + expect(booking.customsClearingEnabled).toBe(false); + }); + + it('carries the contract mile legs so trucking is quoted too', async () => { + const booking = await previewBookingFor( + contract({ + firstMilePickupAddress: 'Modjo Dry Port', + lastMileDeliveryAddress: 'Djibouti Port', + }), + ); + expect(booking.firstMilePickupAddress).toBe('Modjo Dry Port'); + expect(booking.lastMileDeliveryAddress).toBe('Djibouti Port'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 834663170..c1b1459a3 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -39,6 +39,10 @@ import { TrainBuilderService } from './train-builder.service'; FREIGHT_PERMS.trains.update, FREIGHT_PERMS.trains.assignWagons, FREIGHT_PERMS.trains.delete, + FREIGHT_PERMS.trains.changeLocomotives, + FREIGHT_PERMS.trains.changeYard, + FREIGHT_PERMS.trains.toggleActive, + FREIGHT_PERMS.trains.disband, ]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} @@ -72,7 +76,7 @@ export class TrainBuilderController { } @Put(':id/locomotives') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.changeLocomotives) @ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' }) setLocomotives( @Param('id', ParseUUIDPipe) id: string, @@ -94,7 +98,7 @@ export class TrainBuilderController { } @Patch(':id/yard') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.changeYard) @ApiOperation({ summary: 'Relocate the train — its locomotives and wagons move to the new yard with it', }) @@ -143,7 +147,7 @@ export class TrainBuilderController { } @Post(':id/deactivate') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.toggleActive) @ApiOperation({ summary: 'Deactivate the train (park it) — only allowed with no active schedule', }) @@ -152,14 +156,14 @@ export class TrainBuilderController { } @Post(':id/activate') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.toggleActive) @ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' }) activate(@Param('id', ParseUUIDPipe) id: string) { return this.trainBuilderService.activate(id); } @Delete(':id') - @FleetManage(FREIGHT_PERMS.trains.delete) + @FleetManage(FREIGHT_PERMS.trains.disband) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' }) disband(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 06511cb2b..47fde9fb4 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -707,6 +707,29 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:trains:assign_wagons", "Assign wagons to train", ), + // Granular splits of trains:update / trains:delete for the train-builder + // detail page's Actions menu — each item gets its own grant instead of + // sharing the coarse update/delete keys. + perm( + "e1c00001-0001-4000-8000-000000000006", + "edr_freight_app:trains:change_locomotives", + "Change train locomotives", + ), + perm( + "e1c00001-0001-4000-8000-000000000007", + "edr_freight_app:trains:change_yard", + "Change train yard", + ), + perm( + "e1c00001-0001-4000-8000-000000000008", + "edr_freight_app:trains:toggle_active", + "Activate or deactivate train", + ), + perm( + "e1c00001-0001-4000-8000-000000000009", + "edr_freight_app:trains:disband", + "Disband train", + ), perm( "e1d00001-0001-4000-8000-000000000001", "edr_freight_app:routes:view", @@ -1714,6 +1737,10 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:trains:update", delete: "edr_freight_app:trains:delete", assignWagons: "edr_freight_app:trains:assign_wagons", + changeLocomotives: "edr_freight_app:trains:change_locomotives", + changeYard: "edr_freight_app:trains:change_yard", + toggleActive: "edr_freight_app:trains:toggle_active", + disband: "edr_freight_app:trains:disband", }, routes: { view: "edr_freight_app:routes:view", @@ -2021,6 +2048,10 @@ const FLEET_GRANULAR_KEYS: string[] = [ FREIGHT_PERMS.trains.update, FREIGHT_PERMS.trains.delete, FREIGHT_PERMS.trains.assignWagons, + FREIGHT_PERMS.trains.changeLocomotives, + FREIGHT_PERMS.trains.changeYard, + FREIGHT_PERMS.trains.toggleActive, + FREIGHT_PERMS.trains.disband, FREIGHT_PERMS.routes.view, FREIGHT_PERMS.routes.create, FREIGHT_PERMS.routes.update, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 09790091d..b74c7bef9 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -188,6 +188,11 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:trains:update", delete: "edr_freight_app:trains:delete", assignWagons: "edr_freight_app:trains:assign_wagons", + /** Train-builder detail Actions menu — each item its own grant. */ + changeLocomotives: "edr_freight_app:trains:change_locomotives", + changeYard: "edr_freight_app:trains:change_yard", + toggleActive: "edr_freight_app:trains:toggle_active", + disband: "edr_freight_app:trains:disband", }, routes: { view: "edr_freight_app:routes:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx index 5291e26f1..400bfc1e5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx @@ -47,7 +47,7 @@ import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompo import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; import { useAuth } from "@/auth/useAuth"; -import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { useToast } from "@/hooks/use-toast"; import type { TrainCompositionWagon } from "@/services/trainBuilder.service"; @@ -83,9 +83,11 @@ export default function TrainBuilderDetailPage() { const [maintenanceTarget, setMaintenanceTarget] = useState(null); const { user } = useAuth(); - const canUpdate = canFleetAction(user, "trains", "update"); - const canDelete = canFleetAction(user, "trains", "delete"); const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons); + const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives); + const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard); + const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive); + const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband); const compositionQuery = useQuery( api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }), @@ -172,7 +174,7 @@ export default function TrainBuilderDetailPage() { } action={ - canUpdate || canDelete ? ( + canChangeLocomotives || canChangeYard || canToggleActive || canDisband ? ( - {canUpdate ? ( - <> - } - disabled={!composition.editable} - onClick={() => setLocoModalOpen(true)} - > - Change locomotives - - } - disabled={!composition.editable} - onClick={() => setYardModalOpen(true)} - > - Change yard - - {composition.status === "DEACTIVATED" ? ( - } - disabled={blockingLocomotives.length > 0} - onClick={() => - void withToast(async () => { - await activate.mutateAsync(composition.id); - toast({ title: `Train ${composition.code} reactivated` }); - }, "Could not reactivate train") - } - > - Reactivate train - - ) : ( - } - disabled={composition.activeSchedules.length > 0} - onClick={() => setDeactivateOpen(true)} - > - Deactivate train - - )} - + {canChangeLocomotives ? ( + } + disabled={!composition.editable} + onClick={() => setLocoModalOpen(true)} + > + Change locomotives + ) : null} - {canDelete ? ( + {canChangeYard ? ( + } + disabled={!composition.editable} + onClick={() => setYardModalOpen(true)} + > + Change yard + + ) : null} + {canToggleActive ? ( + composition.status === "DEACTIVATED" ? ( + } + disabled={blockingLocomotives.length > 0} + onClick={() => + void withToast(async () => { + await activate.mutateAsync(composition.id); + toast({ title: `Train ${composition.code} reactivated` }); + }, "Could not reactivate train") + } + > + Reactivate train + + ) : ( + } + disabled={composition.activeSchedules.length > 0} + onClick={() => setDeactivateOpen(true)} + > + Deactivate train + + ) + ) : null} + {canDisband ? ( } @@ -277,7 +281,7 @@ export default function TrainBuilderDetailPage() { . - {canUpdate ? ( + {canChangeLocomotives ? ( - - + + )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index f5be74416..9c6974641 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -87,6 +87,8 @@ export default function NewShipmentRequestPage() { contract.contractKind === "GENERAL" && (contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled); const isIntercity = contract.tradeDirection === "DOMESTIC"; + // Export shipments are invoiced in ETB only — USD is not offered. + const isExport = contract.tradeDirection === "EXPORT"; // Only the container sizes the contract was scoped for (20ft, 40ft, or both). const SIZE_ORDER = ["20ft", "40ft"]; @@ -115,7 +117,7 @@ export default function NewShipmentRequestPage() { const dto: Freight.CreateBookingRequestDto = { contractRouteId: route?.id, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, - paymentCurrency: isIntercity ? "ETB" : paymentCurrency, + paymentCurrency: isIntercity || isExport ? "ETB" : paymentCurrency, notes: notes.trim() || undefined, }; @@ -246,16 +248,22 @@ export default function NewShipmentRequestPage() { {isIntercity ? "Intercity shipments are invoiced in ETB." - : "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."} + : isExport + ? "Export shipments are invoiced in ETB." + : "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."} setPaymentCurrency(v as "USD" | "ETB")} - disabled={isIntercity} - data={[ - { label: "USD", value: "USD" }, - { label: "ETB", value: "ETB" }, - ]} + disabled={isIntercity || isExport} + data={ + isExport + ? [{ label: "ETB", value: "ETB" }] + : [ + { label: "USD", value: "USD" }, + { label: "ETB", value: "ETB" }, + ] + } color="teal" radius={10} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css b/apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css new file mode 100644 index 000000000..270b91e16 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css @@ -0,0 +1,11 @@ +/* Keeps the fixed sign bar confined to the content area (right of the + navbar) instead of spanning the full viewport and drifting off-center. */ +.contract-sign-bar { + --sign-bar-left: 0px; +} + +@media (min-width: 48em) { + .contract-sign-bar { + --sign-bar-left: 260px; + } +}