Merge pull request #1238 from Tria-plc/staging

Staging
This commit is contained in:
marshal
2026-08-11 12:06:17 +03:00
committed by GitHub
23 changed files with 715 additions and 122 deletions

View File

@@ -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,
},
};

View File

@@ -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

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.file_upload_fields
ALTER COLUMN max_size_mb SET DEFAULT 10
`);
}
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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> = {}): 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<Booking> => {
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<unknown>;
}
).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');
});
});

View File

@@ -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 })

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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,
},
];

View File

@@ -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,

View File

@@ -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",

View File

@@ -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<TrainCompositionWagon | null>(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() {
</Group>
}
action={
canUpdate || canDelete ? (
canChangeLocomotives || canChangeYard || canToggleActive || canDisband ? (
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
<Menu.Target>
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
@@ -180,47 +182,49 @@ export default function TrainBuilderDetailPage() {
</Button>
</Menu.Target>
<Menu.Dropdown>
{canUpdate ? (
<>
<Menu.Item
leftSection={<Replace size={15} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Change locomotives
</Menu.Item>
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
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
</Menu.Item>
) : (
<Menu.Item
leftSection={<PowerOff size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDeactivateOpen(true)}
>
Deactivate train
</Menu.Item>
)}
</>
{canChangeLocomotives ? (
<Menu.Item
leftSection={<Replace size={15} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Change locomotives
</Menu.Item>
) : null}
{canDelete ? (
{canChangeYard ? (
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
) : null}
{canToggleActive ? (
composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
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
</Menu.Item>
) : (
<Menu.Item
leftSection={<PowerOff size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDeactivateOpen(true)}
>
Deactivate train
</Menu.Item>
)
) : null}
{canDisband ? (
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
@@ -277,7 +281,7 @@ export default function TrainBuilderDetailPage() {
.
</Text>
<Group gap="xs">
{canUpdate ? (
{canChangeLocomotives ? (
<Button
size="compact-sm"
variant="light"

View File

@@ -33,6 +33,8 @@ import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api";
import { extractApiError } from "@/utils/result";
import "./contract-sign-bar.css";
const CONSENT_TEXT = "I have read the entire contract and agree to its terms.";
/**
@@ -225,7 +227,7 @@ export default function ContractViewPage() {
return (
<Box
p={{ base: "md", md: "xl" }}
pb={data.canSignCustomer ? 120 : undefined}
pb={data.canSignCustomer ? { base: 220, sm: 160, md: 120 } : undefined}
>
<Box maw={920} mx="auto">
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
@@ -294,16 +296,19 @@ export default function ContractViewPage() {
style={{
position: "fixed",
bottom: 0,
left: 0,
left: "var(--sign-bar-left, 0px)",
right: 0,
zIndex: 100,
borderTop: "1px solid var(--mantine-color-gray-3)",
background: "var(--mantine-color-body)",
paddingBottom: 32,
paddingBottom: "max(env(safe-area-inset-bottom, 0px), 16px)",
maxHeight: "80vh",
overflowY: "auto",
}}
className="contract-sign-bar"
>
<Box maw={920} mx="auto">
<Stack gap="sm">
<Group justify="flex-start" align="flex-start" wrap="wrap" gap="sm">
<Checkbox
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
@@ -315,17 +320,15 @@ export default function ContractViewPage() {
: "Read the full contract above before you can agree and sign."
}
/>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
disabled={!canProceedToSign}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Group>
</Stack>
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
disabled={!canProceedToSign}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Group>
</Box>
</Paper>
)}

View File

@@ -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() {
<Text size="xs" c="dimmed" mb={8}>
{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."}
</Text>
<SegmentedControl
value={isIntercity ? "ETB" : paymentCurrency}
value={isIntercity || isExport ? "ETB" : paymentCurrency}
onChange={(v) => 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}
/>

View File

@@ -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;
}
}

View File

@@ -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<T> {
const url = `${this.baseUrl}${path}`;
this.logger.log("=====================================================================");
this.logger.log(`URL ${url}`);
this.logger.log("=====================================================================");
try {
const response = await firstValueFrom(
this.http.request<T>({

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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);
});
});
});

View File

@@ -49,6 +49,14 @@ export interface ProviderResultInput {
rawResponse?: Record<string, unknown>;
}
/**
* 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). */

86
docs/uploads.md Normal file
View File

@@ -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
13 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/<id>/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.

View File

@@ -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;