mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
30
apps/edr-freight-api/src/common/document-upload.options.ts
Normal file
30
apps/edr-freight-api/src/common/document-upload.options.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
86
docs/uploads.md
Normal 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
|
||||
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/<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.
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user