mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
@@ -57,6 +58,24 @@ import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'
|
||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
||||
|
||||
/**
|
||||
* The allocated train as the backoffice booking detail page needs it: which
|
||||
* train, its window phase, and both the planned and actual clock. Attached by
|
||||
* `findById` only when the booking is on a schedule.
|
||||
*/
|
||||
export interface TrainScheduleSummary {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
trainNumber: string | null;
|
||||
status: string | null;
|
||||
scheduledDepartureDate: string | null;
|
||||
scheduledArrivalDate: string | null;
|
||||
actualDepartureAt: string | null;
|
||||
actualArrivalAt: string | null;
|
||||
windowPhase: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
}
|
||||
|
||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
export interface PaginatedBookings {
|
||||
items: Booking[];
|
||||
@@ -1738,6 +1757,20 @@ export class BookingsService {
|
||||
(b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
|
||||
pending.has(b.id);
|
||||
}
|
||||
this.attachPaymentDrainEnds(bookings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived, no query: end of the settlement drain tail after `paymentDeadline`.
|
||||
* The portal hides "Pay now" between the deadline and this instant — a payment
|
||||
* started just before the buzzer is still settling, so offering to pay again
|
||||
* would invite a double payment.
|
||||
*/
|
||||
private attachPaymentDrainEnds(bookings: Booking[]): void {
|
||||
for (const b of bookings) {
|
||||
(b as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt =
|
||||
paymentDrainEndsAtIso(b.paymentDeadline);
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(
|
||||
@@ -2105,8 +2138,33 @@ export class BookingsService {
|
||||
.findOne({ where: { id: booking.trainScheduleId } });
|
||||
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
|
||||
schedule?.status ?? null;
|
||||
// Backoffice staff view: the allocated train's identity and clock, so the
|
||||
// detail page can state which train the booking rides and when it runs
|
||||
// without a second round-trip to the schedules API.
|
||||
(
|
||||
booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null }
|
||||
).trainScheduleSummary = schedule
|
||||
? {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
status: schedule.status ?? null,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
|
||||
scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null,
|
||||
actualDepartureAt: schedule.actualDepartureAt?.toISOString() ?? null,
|
||||
actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null,
|
||||
windowPhase: schedule.windowPhase ?? null,
|
||||
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// End of this booking's own pay window including the settlement drain tail —
|
||||
// the deadline staff should quote, since a payment landing inside the drain
|
||||
// still counts (see paymentDrainEndsAtIso).
|
||||
(booking as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt =
|
||||
paymentDrainEndsAtIso(booking.paymentDeadline);
|
||||
|
||||
// A generated-but-unsigned handover means the customer must approve delivery
|
||||
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
|
||||
// per delivering truck (generated on truck exit), signed one by one.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3680,23 +3680,24 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// (provider query errored / payment still in flight) means we could not
|
||||
// confirm "not paid" — never expire on unknown; the next settle tick
|
||||
// asks again.
|
||||
if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) {
|
||||
const reconcile = await this.billing.reconcilePayable(booking.id);
|
||||
if (reconcile.paid) {
|
||||
this.logger.log(
|
||||
`[BATCH] expire skipped for ${booking.reference} — gateway ` +
|
||||
`reconcile found a settled payment; payment.succeeded will allocate it`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (reconcile.unverifiable) {
|
||||
this.logger.warn(
|
||||
`[BATCH] expire deferred for ${booking.reference} — settlement ` +
|
||||
`unverifiable at the gateway; retrying next settle tick`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// TODO: CBE has no reconcile endpoint yet — re-enable once available.
|
||||
// if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) {
|
||||
// const reconcile = await this.billing.reconcilePayable(booking.id);
|
||||
// if (reconcile.paid) {
|
||||
// this.logger.log(
|
||||
// `[BATCH] expire skipped for ${booking.reference} — gateway ` +
|
||||
// `reconcile found a settled payment; payment.succeeded will allocate it`,
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// if (reconcile.unverifiable) {
|
||||
// this.logger.warn(
|
||||
// `[BATCH] expire deferred for ${booking.reference} — settlement ` +
|
||||
// `unverifiable at the gateway; retrying next settle tick`,
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
const freedScheduleId = booking.trainScheduleId;
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
|
||||
@@ -510,6 +510,8 @@ export class TrainBuilderService {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Available,
|
||||
importTrainNumber: null,
|
||||
exportTrainNumber: null,
|
||||
});
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
await this.syncLiveScheduleAfterConsistChange(
|
||||
@@ -544,6 +546,8 @@ export class TrainBuilderService {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Maintenance,
|
||||
importTrainNumber: null,
|
||||
exportTrainNumber: null,
|
||||
});
|
||||
// Audit row: which train it came off and when. The wagon does not change
|
||||
// yard here, so from/to are the same — the ledger is the wagon's history
|
||||
@@ -761,7 +765,13 @@ export class TrainBuilderService {
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
{ trainId: train.id },
|
||||
{ trainId: null, sequenceNumber: null, status: WagonStatus.Available },
|
||||
{
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Available,
|
||||
importTrainNumber: null,
|
||||
exportTrainNumber: null,
|
||||
},
|
||||
);
|
||||
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
|
||||
await manager.getRepository(Train).remove(train);
|
||||
@@ -1021,6 +1031,10 @@ export class TrainBuilderService {
|
||||
trainId: train.id,
|
||||
sequenceNumber: sequence,
|
||||
status: WagonStatus.Assigned,
|
||||
// Wagon inherits the train's run numbers on coupling — no per-wagon
|
||||
// number entry, they ride whatever numbers the train was built with.
|
||||
importTrainNumber: train.importTrainNumber,
|
||||
exportTrainNumber: train.exportTrainNumber,
|
||||
});
|
||||
}
|
||||
return toAttach;
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
* A count-only wagon-transfer request. The requester picks source yard, wagon
|
||||
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
|
||||
* those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
|
||||
* type currently in the source yard, and a reason is mandatory.
|
||||
* type currently in the source yard (enforced in the service, which is the only
|
||||
* layer that can count them), and a reason is mandatory.
|
||||
*/
|
||||
export class CreateTransferRequestDto {
|
||||
@IsUUID()
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
|
||||
});
|
||||
|
||||
describe('createRequest', () => {
|
||||
it('accepts a count larger than what the yard holds today', async () => {
|
||||
it('accepts a count up to what the yard holds today', async () => {
|
||||
wagonRepo.count.mockResolvedValue(20);
|
||||
|
||||
await service.createRequest(
|
||||
@@ -207,14 +207,50 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 50,
|
||||
quantity: 20,
|
||||
reason: 'Grain campaign',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(requestRepo.save).toHaveBeenCalled();
|
||||
expect(stored.quantity).toBe(50);
|
||||
expect(stored.quantity).toBe(20);
|
||||
});
|
||||
|
||||
it('refuses a count larger than what the yard holds today', async () => {
|
||||
wagonRepo.count.mockResolvedValue(20);
|
||||
|
||||
await expect(
|
||||
service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 50,
|
||||
reason: 'Grain campaign',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(/only 20 wagon\(s\).*available/i);
|
||||
expect(requestRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses when the yard has nothing of that type available', async () => {
|
||||
wagonRepo.count.mockResolvedValue(0);
|
||||
|
||||
await expect(
|
||||
service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 1,
|
||||
reason: 'Grain campaign',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(/no available wagons/i);
|
||||
expect(requestRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still refuses a same-yard move', async () => {
|
||||
|
||||
@@ -75,10 +75,11 @@ export class WagonTransferRequestsService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Record a PENDING request. Count-only — no wagons are picked here, and the
|
||||
* count is NOT capped by what the source yard holds today: OCC fulfils in
|
||||
* instalments, so asking for 50 while only 20 sit there is a normal, useful
|
||||
* request. A reason is mandatory and is shown on the OCC queue.
|
||||
* Record a PENDING request. Count-only — no wagons are picked here, but the
|
||||
* count IS capped by what the source yard can hand over right now: a request
|
||||
* may not exceed the AVAILABLE, uncoupled wagons of that type in the source
|
||||
* yard (the same number the yard desk shows). A reason is mandatory and is
|
||||
* shown on the OCC queue.
|
||||
*/
|
||||
async createRequest(
|
||||
dto: CreateTransferRequestDto,
|
||||
@@ -89,6 +90,20 @@ export class WagonTransferRequestsService {
|
||||
'Source and destination yard must be different',
|
||||
);
|
||||
}
|
||||
const available = await this.countAvailable(
|
||||
dto.fromYardId,
|
||||
dto.wagonTypeId,
|
||||
);
|
||||
if (available === 0) {
|
||||
throw new BadRequestException(
|
||||
'No available wagons of this type in the source yard',
|
||||
);
|
||||
}
|
||||
if (dto.quantity > available) {
|
||||
throw new BadRequestException(
|
||||
`Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`,
|
||||
);
|
||||
}
|
||||
const request = this.requestRepo.create({
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
export interface BookingSchedulingWindowCardProps {
|
||||
booking: BookingDetail;
|
||||
}
|
||||
|
||||
/** Full date + time — staff read these against the operating clock, so no time is dropped. */
|
||||
function formatStamp(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const ms = new Date(iso).getTime();
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
return new Date(ms).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */
|
||||
function formatRelative(iso: string, nowMs: number): string {
|
||||
const diff = new Date(iso).getTime() - nowMs;
|
||||
const past = diff < 0;
|
||||
const totalMinutes = Math.floor(Math.abs(diff) / 60_000);
|
||||
const days = Math.floor(totalMinutes / 1440);
|
||||
const hours = Math.floor((totalMinutes % 1440) / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days) parts.push(`${days}d`);
|
||||
if (hours) parts.push(`${hours}h`);
|
||||
// Keep minutes when they're the only unit, so sub-hour gaps never read "0".
|
||||
if (minutes || parts.length === 0) parts.push(`${minutes}m`);
|
||||
|
||||
const span = parts.slice(0, 2).join(" ");
|
||||
return past ? `${span} ago` : `in ${span}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of a window as "1h 30m" / "45m". Null unless both ends are real and
|
||||
* ordered — the pay window is configurable per schedule, so this is read off the
|
||||
* actual stamps rather than assuming any fixed duration.
|
||||
*/
|
||||
function formatDuration(
|
||||
from: string | null | undefined,
|
||||
to: string | null | undefined,
|
||||
): string | null {
|
||||
if (!from || !to) return null;
|
||||
const fromMs = new Date(from).getTime();
|
||||
const toMs = new Date(to).getTime();
|
||||
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null;
|
||||
const minutes = Math.round((toMs - fromMs) / 60_000);
|
||||
if (minutes <= 0) return null;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
if (!hours) return `${rest}m`;
|
||||
return rest ? `${hours}h ${rest}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string | null;
|
||||
tone?: "muted" | "warning" | "danger";
|
||||
}) {
|
||||
const valueColor =
|
||||
tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark";
|
||||
return (
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="md">
|
||||
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Box style={{ textAlign: "right", minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} c={valueColor}>
|
||||
{value}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice-only staff view of the scheduling clock: which batch/train the
|
||||
* booking is scheduled for, when its pay window closes, and the train's
|
||||
* planned vs actual departure/arrival (i.e. when the run actually ended).
|
||||
*/
|
||||
export function BookingSchedulingWindowCard({
|
||||
booking,
|
||||
}: BookingSchedulingWindowCardProps) {
|
||||
const schedule = booking.trainScheduleSummary ?? null;
|
||||
|
||||
// The pay-window end staff should quote is the drain end (a payment landing
|
||||
// inside the drain still counts); fall back to the raw deadline if the API
|
||||
// predates that field.
|
||||
const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null;
|
||||
|
||||
// One shared ticking clock so every relative label in the card stays in sync.
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNowMs(Date.now()), 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// How long the customer actually had to pay: start → the raw deadline, NOT the
|
||||
// drain end (the drain is settlement grace, not payable time).
|
||||
const windowDuration = formatDuration(
|
||||
booking.selectedForBatchAt,
|
||||
booking.paymentDeadline,
|
||||
);
|
||||
|
||||
const hasAnything =
|
||||
Boolean(schedule) ||
|
||||
Boolean(payWindowEndsAt) ||
|
||||
Boolean(booking.selectedForBatchAt) ||
|
||||
Boolean(booking.holdExpiresAt);
|
||||
if (!hasAnything) return null;
|
||||
|
||||
const payWindowClosed = payWindowEndsAt
|
||||
? new Date(payWindowEndsAt).getTime() <= nowMs
|
||||
: false;
|
||||
|
||||
const trainLabel =
|
||||
schedule?.trainNumber ??
|
||||
schedule?.reference ??
|
||||
(schedule ? "Assigned train" : null);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={CalendarClock}
|
||||
title="Scheduling & payment window"
|
||||
subtitle="Staff view — batch allocation and the operating clock"
|
||||
accent="indigo"
|
||||
extra={<SchedulingStatusBadge status={booking.schedulingStatus} />}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{trainLabel ? (
|
||||
<Row
|
||||
label="Scheduled on train"
|
||||
value={trainLabel}
|
||||
hint={
|
||||
schedule?.reference && schedule.reference !== trainLabel
|
||||
? schedule.reference
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label="Scheduled on train"
|
||||
value="Not yet allocated"
|
||||
tone="muted"
|
||||
hint="The booking has not been placed on a train schedule"
|
||||
/>
|
||||
)}
|
||||
|
||||
{schedule?.status ? (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
Train status
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{schedule.windowPhase ? (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{schedule.windowPhase.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="light" color="indigo" size="sm">
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{booking.selectedForBatchAt ? (
|
||||
<Row
|
||||
label="Payment window started"
|
||||
value={formatStamp(booking.selectedForBatchAt) ?? "—"}
|
||||
hint={
|
||||
windowDuration
|
||||
? `${windowDuration} window`
|
||||
: formatRelative(booking.selectedForBatchAt, nowMs)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{payWindowEndsAt ? (
|
||||
<Row
|
||||
label="Payment window ends"
|
||||
value={formatStamp(payWindowEndsAt) ?? "—"}
|
||||
tone={payWindowClosed ? "danger" : "warning"}
|
||||
hint={
|
||||
payWindowClosed
|
||||
? `Closed ${formatRelative(payWindowEndsAt, nowMs)}`
|
||||
: `Closes ${formatRelative(payWindowEndsAt, nowMs)}`
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
<Row
|
||||
label="Wagon hold expires"
|
||||
value={formatStamp(booking.holdExpiresAt) ?? "—"}
|
||||
tone="warning"
|
||||
hint={formatRelative(booking.holdExpiresAt, nowMs)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{schedule ? (
|
||||
<>
|
||||
<Row
|
||||
label="Departure"
|
||||
value={
|
||||
formatStamp(schedule.actualDepartureAt) ??
|
||||
formatStamp(schedule.scheduledDepartureDate) ??
|
||||
"—"
|
||||
}
|
||||
hint={
|
||||
schedule.actualDepartureAt
|
||||
? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}`
|
||||
: "Planned"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label={schedule.actualArrivalAt ? "Arrived (trip ended)" : "Arrival"}
|
||||
value={
|
||||
formatStamp(schedule.actualArrivalAt) ??
|
||||
formatStamp(schedule.scheduledArrivalDate) ??
|
||||
"—"
|
||||
}
|
||||
hint={
|
||||
schedule.actualArrivalAt
|
||||
? `Actual · planned ${formatStamp(schedule.scheduledArrivalDate) ?? "—"}`
|
||||
: "Planned — the train has not arrived yet"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard";
|
||||
export * from "./BookingCargoCard";
|
||||
export * from "./BookingContractSummaryCard";
|
||||
export * from "./BookingCompanyCard";
|
||||
export * from "./BookingSchedulingWindowCard";
|
||||
|
||||
@@ -44,9 +44,8 @@ const clampInt = (v: number | string, max: number): number => {
|
||||
|
||||
/**
|
||||
* NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field
|
||||
* for actions that move real wagons; omit it for a transfer REQUEST, which may
|
||||
* legitimately ask for more than the yard holds today (OCC fulfils it in
|
||||
* instalments) — the slider then just tracks the current value.
|
||||
* to the wagons on hand; omitting it leaves the field unbounded and the slider
|
||||
* simply tracks the current value.
|
||||
*/
|
||||
const QuantityField = ({
|
||||
value,
|
||||
@@ -434,9 +433,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
{availableCount} available
|
||||
</Badge>
|
||||
</Group>
|
||||
{/* No max: the request may exceed what the yard holds
|
||||
today — OCC fulfils it in instalments. */}
|
||||
<QuantityField value={transferQty} onChange={setTransferQty} />
|
||||
{/* Capped at the wagons actually available in this yard
|
||||
right now (uncoupled + Available) — a request may not
|
||||
ask for more than the yard can hand over. */}
|
||||
<QuantityField
|
||||
value={transferQty}
|
||||
onChange={setTransferQty}
|
||||
max={availableCount}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
BookingCompanyCard,
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
BookingSchedulingWindowCard,
|
||||
BookingDocumentsPanel,
|
||||
BookingTrucksPanel,
|
||||
ContractOrdersPanel,
|
||||
@@ -246,6 +247,7 @@ export default function BookingRequestDetailPage() {
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingSchedulingWindowCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<Box id="warehouse-payments">
|
||||
<WarehouseInfoCard
|
||||
|
||||
@@ -102,6 +102,7 @@ const FleetResourcePage = () => {
|
||||
const currentYardId = listFilterValues.currentYardId;
|
||||
const availability = listFilterValues.availability;
|
||||
const trainNumber = listFilterValues.trainNumber;
|
||||
const trainId = listFilterValues.trainId;
|
||||
if (status && status !== "ALL") {
|
||||
(filters as { status?: string }).status = status;
|
||||
}
|
||||
@@ -114,6 +115,9 @@ const FleetResourcePage = () => {
|
||||
if (trainNumber && trainNumber !== "ALL") {
|
||||
(filters as { trainNumber?: string }).trainNumber = trainNumber;
|
||||
}
|
||||
if (trainId && trainId !== "ALL") {
|
||||
filters.trainId = trainId;
|
||||
}
|
||||
// Wagons only: narrow the fleet to one wagon type (the API filters on it).
|
||||
const wagonTypeId = listFilterValues.wagonTypeId;
|
||||
if (wagonTypeId && wagonTypeId !== "ALL") {
|
||||
@@ -191,6 +195,11 @@ const FleetResourcePage = () => {
|
||||
const { data: drivers = [] } = useQuery(
|
||||
api.fleet.list.queryOptions({ input: { slug: "drivers" } }),
|
||||
);
|
||||
// Wagons-only: "Train" list filter needs every train's code to pick from.
|
||||
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
||||
...api.trains.list.queryOptions(),
|
||||
enabled: slug === "wagons",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
@@ -247,6 +256,9 @@ const FleetResourcePage = () => {
|
||||
const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map(
|
||||
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
|
||||
);
|
||||
const trainOpts = (trains as Array<{ id: string; code: string; trainName?: string | null }>).map(
|
||||
(t) => ({ value: t.id, label: t.trainName ? `${t.code} - ${t.trainName}` : t.code }),
|
||||
);
|
||||
|
||||
// Carries capacity + trailer configuration so picking a truck type can
|
||||
// pre-fill the vehicle's capacity and drop the trailer plate on a rigid type.
|
||||
@@ -274,8 +286,9 @@ const FleetResourcePage = () => {
|
||||
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
|
||||
containers: containerOpts,
|
||||
yards: yardOpts,
|
||||
trains: trainOpts,
|
||||
};
|
||||
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]);
|
||||
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]);
|
||||
|
||||
const listFilterSelects = useMemo(() => {
|
||||
if (!config?.listFilters?.length) return null;
|
||||
@@ -327,7 +340,8 @@ const FleetResourcePage = () => {
|
||||
truckTypesLoading ||
|
||||
wagonsLoading ||
|
||||
containersLoading ||
|
||||
yardsLoading;
|
||||
yardsLoading ||
|
||||
trainsLoading;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!config) return allRows;
|
||||
|
||||
@@ -33,7 +33,8 @@ export type FleetDynamicOptions =
|
||||
| "truckTypes"
|
||||
| "wagons"
|
||||
| "containers"
|
||||
| "yards";
|
||||
| "yards"
|
||||
| "trains";
|
||||
|
||||
/**
|
||||
* A dynamic select option that can carry the record it came from. Picking a
|
||||
@@ -324,6 +325,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
allLabel: "All trains",
|
||||
options: TRAIN_RUN_FILTER_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "trainId",
|
||||
label: "Train",
|
||||
allLabel: "All trains",
|
||||
dynamicOptions: "trains",
|
||||
},
|
||||
],
|
||||
cardTitleKey: "wagonNumber",
|
||||
cardSubtitleKey: "currentYard",
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
STATUS_META,
|
||||
TransferProgress,
|
||||
TransferStatusBadge,
|
||||
stripHtmlToText,
|
||||
wagonTypeLabel,
|
||||
yardLabel,
|
||||
} from "./wagon-transfer-ui";
|
||||
@@ -119,9 +120,9 @@ function RequestItem({ request }: { request: WagonTransferRequest }) {
|
||||
{wagonTypeLabel(request.wagonType)}
|
||||
</Badge>
|
||||
</Group>
|
||||
{request.reason ? (
|
||||
{stripHtmlToText(request.reason) ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{request.reason}
|
||||
{stripHtmlToText(request.reason)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -41,6 +42,30 @@ function useTransferOptions(enabled: boolean) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons the source yard can hand over right now — AVAILABLE and not coupled to
|
||||
* a built train. Mirrors `countAvailable` on the API, which rejects any request
|
||||
* asking for more than this, so the field must not let one be filed.
|
||||
*/
|
||||
function useAvailableCount(
|
||||
enabled: boolean,
|
||||
fromYardId: string | null,
|
||||
wagonTypeId: string | null,
|
||||
) {
|
||||
const { data: wagons = [] } = useQuery({
|
||||
...api.wagons.list.queryOptions({ input: {} }),
|
||||
enabled: enabled && Boolean(fromYardId && wagonTypeId),
|
||||
});
|
||||
if (!fromYardId || !wagonTypeId) return null;
|
||||
return wagons.filter(
|
||||
(w) =>
|
||||
w.currentYardId === fromYardId &&
|
||||
w.wagonTypeId === wagonTypeId &&
|
||||
w.status === Freight.WagonStatus.Available &&
|
||||
!w.trainId,
|
||||
).length;
|
||||
}
|
||||
|
||||
export interface TransferRequestFormModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
@@ -54,9 +79,9 @@ export interface TransferRequestFormModalProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* File a wagon-transfer request. The count is deliberately NOT capped by what
|
||||
* the source yard holds today — OCC fulfils in instalments, so asking for 50
|
||||
* where 20 sit is a normal request.
|
||||
* File a wagon-transfer request. The count is capped by what the source yard
|
||||
* has available right now; the API enforces the same ceiling, so a larger ask
|
||||
* is rejected rather than queued.
|
||||
*/
|
||||
export function TransferRequestFormModal({
|
||||
opened,
|
||||
@@ -83,10 +108,23 @@ export function TransferRequestFormModal({
|
||||
|
||||
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
|
||||
|
||||
const available = useAvailableCount(opened, fromYardId, wagonTypeId);
|
||||
|
||||
// A prefilled outstanding count (or a count typed before the yard was picked)
|
||||
// can exceed what the chosen source yard actually has — pull it back down so
|
||||
// the field never holds a value the API would reject.
|
||||
useEffect(() => {
|
||||
if (available == null) return;
|
||||
setQuantity((q) => (Number(q) > available ? available : q));
|
||||
}, [available]);
|
||||
|
||||
const sameYard = Boolean(fromYardId && fromYardId === toYardId);
|
||||
const overAvailable = available != null && Number(quantity) > available;
|
||||
const valid =
|
||||
Boolean(fromYardId && toYardId && wagonTypeId && reason.trim()) &&
|
||||
!sameYard &&
|
||||
!overAvailable &&
|
||||
available !== 0 &&
|
||||
Number(quantity) >= 1;
|
||||
|
||||
const submit = async () => {
|
||||
@@ -155,10 +193,25 @@ export function TransferRequestFormModal({
|
||||
/>
|
||||
<NumberInput
|
||||
label="How many"
|
||||
description="Can exceed what the yard holds today — OCC delivers in instalments"
|
||||
description={
|
||||
available == null
|
||||
? "Pick a source yard and wagon type to see what is available"
|
||||
: `${available} wagon(s) available in the source yard`
|
||||
}
|
||||
min={1}
|
||||
max={available ?? undefined}
|
||||
clampBehavior={available == null ? "none" : "strict"}
|
||||
allowNegative={false}
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
disabled={available === 0}
|
||||
error={
|
||||
available === 0
|
||||
? "This yard has no wagons of that type available"
|
||||
: overAvailable
|
||||
? `Only ${available} available`
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -31,6 +32,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { sanitizeHtml } from "@/shared/lib/sanitize";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
TransferRequestListFilter,
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
fmtDateTime,
|
||||
isOpenRequest,
|
||||
outstandingOn,
|
||||
stripHtmlToText,
|
||||
wagonTypeLabel,
|
||||
yardLabel,
|
||||
} from "./wagon-transfer-ui";
|
||||
@@ -108,6 +111,9 @@ export default function WagonTransfersPage() {
|
||||
const [closingShort, setClosingShort] = useState<WagonTransferRequest | null>(
|
||||
null,
|
||||
);
|
||||
const [viewingReason, setViewingReason] = useState<WagonTransferRequest | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const filter: TransferRequestListFilter = useMemo(
|
||||
() => ({
|
||||
@@ -197,11 +203,29 @@ export default function WagonTransfersPage() {
|
||||
{
|
||||
id: "reason",
|
||||
header: () => <span>Reason</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed" lineClamp={2} maw={260}>
|
||||
{row.original.reason || "—"}
|
||||
</Text>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const text = stripHtmlToText(row.original.reason);
|
||||
return text ? (
|
||||
<UnstyledButton
|
||||
onClick={() => setViewingReason(row.original)}
|
||||
data-stop-row-click
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
lineClamp={2}
|
||||
maw={260}
|
||||
style={{ textAlign: "left", textDecoration: "underline dotted" }}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filed",
|
||||
@@ -522,6 +546,33 @@ export default function WagonTransfersPage() {
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
opened={Boolean(viewingReason)}
|
||||
onClose={() => setViewingReason(null)}
|
||||
radius="md"
|
||||
title="Reason"
|
||||
>
|
||||
{!viewingReason ? null : (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{yardLabel(viewingReason.fromYard)}{" "}
|
||||
<ArrowRight
|
||||
size={13}
|
||||
className="inline-block opacity-60"
|
||||
/>{" "}
|
||||
{yardLabel(viewingReason.toYard)} ·{" "}
|
||||
{wagonTypeLabel(viewingReason.wagonType)} ·{" "}
|
||||
{viewingReason.quantity} wagon(s)
|
||||
</Text>
|
||||
<Box
|
||||
className="text-sm [&_p]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(viewingReason.reason ?? ""),
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,16 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||
|
||||
/** Reason/note fields come from a rich-text editor and store HTML — this
|
||||
* gives a plain-text preview for list/table contexts (full formatting is
|
||||
* shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */
|
||||
export const stripHtmlToText = (html?: string | null): string =>
|
||||
(html ?? "")
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
export const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
||||
y?.label || y?.code || "—";
|
||||
|
||||
|
||||
@@ -131,6 +131,20 @@ export interface BookingFile {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** The allocated train's identity, window phase, and planned/actual clock. */
|
||||
export interface BookingTrainScheduleSummary {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
trainNumber: string | null;
|
||||
status: string | null;
|
||||
scheduledDepartureDate: string | null;
|
||||
scheduledArrivalDate: string | null;
|
||||
actualDepartureAt: string | null;
|
||||
actualArrivalAt: string | null;
|
||||
windowPhase: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
}
|
||||
|
||||
export interface BookingDetail {
|
||||
id: string;
|
||||
reference: string;
|
||||
@@ -188,6 +202,22 @@ export interface BookingDetail {
|
||||
wagonsRequired?: number | null;
|
||||
scheduledAt?: string | null;
|
||||
trainScheduleId?: string | null;
|
||||
/** Operational status of the allocated train (null until scheduled). */
|
||||
trainScheduleStatus?: string | null;
|
||||
/** The allocated train's identity + clock, attached by the detail endpoint. */
|
||||
trainScheduleSummary?: BookingTrainScheduleSummary | null;
|
||||
/**
|
||||
* When the batch engine picked this booking and opened its pay window — the
|
||||
* start paired with `paymentDeadline` (both are set and cleared together).
|
||||
*/
|
||||
selectedForBatchAt?: string | null;
|
||||
/** End of this booking's pay window (batch/offer deadline). */
|
||||
paymentDeadline?: string | null;
|
||||
/**
|
||||
* End of the pay window including the settlement drain tail — the deadline
|
||||
* staff should quote, since a payment landing inside the drain still counts.
|
||||
*/
|
||||
paymentDrainEndsAt?: string | null;
|
||||
pnrCode?: string | null;
|
||||
firstMilePickupAddress?: string | null;
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { memo } from "react";
|
||||
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
|
||||
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import {
|
||||
@@ -35,8 +36,13 @@ export const BookingRow = memo(function BookingRow({
|
||||
booking.bookingType === "GENERAL_CONTRACT"
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
// A fully-closed pay window (deadline + drain both elapsed) has nothing to pay
|
||||
// against, so the row falls back to its normal action instead of an empty slot.
|
||||
// The drain itself still routes here — PayNowButton renders the wait notice.
|
||||
const canPay =
|
||||
booking.status === payableStatus && booking.paymentStatus !== "PAID";
|
||||
booking.status === payableStatus &&
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
payWindowState(booking).phase !== "closed";
|
||||
// Clearance/operation steps + changes-requested resubmit can be done in place
|
||||
// via a modal on the row.
|
||||
const hasInlineAction = bookingHasInlineAction(booking);
|
||||
|
||||
@@ -302,7 +302,7 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
||||
icon: Wallet,
|
||||
iconColor: "edr-amber-text",
|
||||
tile: "edr-amber-soft",
|
||||
hint: "Selected for batch · payment due within 1 hour",
|
||||
hint: "Selected for batch · payment due before the deadline",
|
||||
step: "edr-accent",
|
||||
badgeLabel: "Pay Now",
|
||||
badgeBg: "edr-amber-soft",
|
||||
|
||||
@@ -18,6 +18,8 @@ import { invoicesService, type PortalInvoice } from "@/services/invoices.service
|
||||
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
|
||||
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
|
||||
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
||||
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
|
||||
import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
|
||||
import {
|
||||
@@ -216,6 +218,11 @@ export function BookingPaymentPanel({
|
||||
(booking.status === "SELECTED_FOR_BATCH" ||
|
||||
Boolean(booking.paymentDeadline));
|
||||
|
||||
// Pay deadline passed but the settlement drain tail hasn't: in-flight payments
|
||||
// are still landing, so the pay action gives way to a processing countdown.
|
||||
const payWindow = payWindowState(booking);
|
||||
const draining = payWindow.phase === "draining" && Boolean(payWindow.drainEndsAt);
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ["booking-invoices", booking.id],
|
||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||
@@ -266,9 +273,11 @@ export function BookingPaymentPanel({
|
||||
{paid ? <CheckCircle2 size={13} /> : showCountdown ? <Timer size={13} /> : null}
|
||||
{paid
|
||||
? "Paid"
|
||||
: showCountdown
|
||||
? "Pay window open"
|
||||
: paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
|
||||
: draining
|
||||
? "Payment processing"
|
||||
: showCountdown
|
||||
? "Pay window open"
|
||||
: paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -279,7 +288,11 @@ export function BookingPaymentPanel({
|
||||
{/* USD: no online payment — bank transfer + slip to Finance, who confirm
|
||||
the payment (backoffice flow lands in a later phase). Shown for any
|
||||
unpaid USD booking, with or without an open pay window. */}
|
||||
{!paid && offlineUsd && (
|
||||
{!paid && draining && payWindow.drainEndsAt && (
|
||||
<PaymentProcessingNotice drainEndsAt={payWindow.drainEndsAt} />
|
||||
)}
|
||||
|
||||
{!paid && !draining && offlineUsd && (
|
||||
<Box
|
||||
mt={14}
|
||||
p={14}
|
||||
@@ -301,7 +314,7 @@ export function BookingPaymentPanel({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showCountdown && booking.paymentDeadline && (
|
||||
{showCountdown && !draining && booking.paymentDeadline && (
|
||||
<Box mt={16}>
|
||||
<Countdown
|
||||
deadline={booking.paymentDeadline}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
import { payWindowState } from "./payments/payment-drain";
|
||||
import { BookingActionButton } from "./clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
|
||||
import {
|
||||
@@ -211,7 +212,14 @@ function PrimaryAction({
|
||||
const payableStatus = isGeneralContract
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
if (status === payableStatus && booking.paymentStatus !== "PAID") {
|
||||
// A fully-closed pay window (deadline + drain both elapsed) falls through to
|
||||
// the default action. The drain itself still routes here — PayNowButton
|
||||
// renders the "payment processing" wait notice instead of a pay action.
|
||||
if (
|
||||
status === payableStatus &&
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
payWindowState(booking).phase !== "closed"
|
||||
) {
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
// Contract ready for the customer's signature → full-page contract viewer.
|
||||
|
||||
@@ -7,6 +7,8 @@ import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper
|
||||
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
||||
import { priceTotal } from "../BookingDetailPage/utils";
|
||||
import { isUsdOfflineBooking } from "./offline-payment";
|
||||
import { payWindowState } from "./payment-drain";
|
||||
import { PaymentProcessingNotice } from "./PaymentProcessingNotice";
|
||||
import { useBookingPayment } from "./useBookingPayment";
|
||||
|
||||
interface PayNowButtonProps {
|
||||
@@ -29,6 +31,24 @@ export function PayNowButton({
|
||||
}: PayNowButtonProps) {
|
||||
const pay = useBookingPayment(booking.id);
|
||||
const pricing = booking.pricingBreakdown;
|
||||
const payWindow = payWindowState(booking);
|
||||
|
||||
// Pay deadline passed but in-flight payments are still settling: show the
|
||||
// drain countdown instead of any pay action, so nobody pays a second time.
|
||||
// Checked before the USD branch — a bank transfer is just as double-payable.
|
||||
if (payWindow.phase === "draining" && payWindow.drainEndsAt) {
|
||||
return (
|
||||
<PaymentProcessingNotice
|
||||
drainEndsAt={payWindow.drainEndsAt}
|
||||
variant="inline"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Window fully over (drain included) — nothing to pay against anymore.
|
||||
if (payWindow.phase === "closed") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// USD is paid by bank transfer and confirmed by Finance — no online payment.
|
||||
if (isUsdOfflineBooking(booking)) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/** mm:ss left until `target`; clamped at zero so it never shows a negative. */
|
||||
function secondsLeft(target: number, now: number): string {
|
||||
const total = Math.max(0, Math.ceil((target - now) / 1000));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export interface PaymentProcessingNoticeProps {
|
||||
/** ISO end of the drain tail — the countdown target. */
|
||||
drainEndsAt: string;
|
||||
/** Compact single-line form for list rows; full block for the detail page. */
|
||||
variant?: "inline" | "block";
|
||||
/** Called once the drain elapses, so the parent can refetch the new state. */
|
||||
onElapsed?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown in place of "Pay now" during the settlement drain tail: the pay deadline
|
||||
* has passed but in-flight payments are still landing, so the customer waits
|
||||
* rather than paying again.
|
||||
*/
|
||||
export function PaymentProcessingNotice({
|
||||
drainEndsAt,
|
||||
variant = "block",
|
||||
onElapsed,
|
||||
}: PaymentProcessingNoticeProps) {
|
||||
const targetMs = new Date(drainEndsAt).getTime();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
const interval = setInterval(() => {
|
||||
const next = Date.now();
|
||||
setNow(next);
|
||||
if (next >= targetMs) {
|
||||
clearInterval(interval);
|
||||
onElapsed?.();
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [targetMs, onElapsed]);
|
||||
|
||||
const remaining = secondsLeft(targetMs, now);
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Group gap={6} align="center" wrap="nowrap">
|
||||
<Loader2 size={13} color="#B07D14" className="animate-spin" />
|
||||
<Text fz={12} fw={700} c="#B07D14" style={{ whiteSpace: "nowrap" }}>
|
||||
Processing · {remaining}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
mt={14}
|
||||
p={14}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#FEF6E6",
|
||||
border: "1px solid #F3E2B8",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} align="center" wrap="nowrap">
|
||||
<Loader2 size={15} color="#9A5B00" className="animate-spin" />
|
||||
<Text fz="13px" fw={800} c="#9A5B00">
|
||||
Payment processing — {remaining} left
|
||||
</Text>
|
||||
</Group>
|
||||
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
|
||||
The payment window has closed and we're confirming the payments that
|
||||
came in. If you already paid, it can take a few minutes to appear —
|
||||
please don't pay again. This page updates on its own.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
/**
|
||||
* Where a booking sits relative to its pay window.
|
||||
*
|
||||
* - `open` — the window is live; the customer can pay.
|
||||
* - `draining` — the deadline passed but the settlement drain tail has not. A
|
||||
* payment started just before the buzzer may still be settling,
|
||||
* so we show "processing" and hide every pay action rather than
|
||||
* invite a second payment for the same booking.
|
||||
* - `closed` — the drain tail elapsed too; the window is over.
|
||||
* - `none` — no deadline on the booking (nothing to gate).
|
||||
*/
|
||||
export type PayWindowPhase = "open" | "draining" | "closed" | "none";
|
||||
|
||||
export interface PayWindowState {
|
||||
phase: PayWindowPhase;
|
||||
/** True only while the customer may actually start a payment. */
|
||||
canPay: boolean;
|
||||
/** End of the drain tail — the countdown target while `draining`. */
|
||||
drainEndsAt: string | null;
|
||||
}
|
||||
|
||||
type PayableBooking = Pick<
|
||||
Freight.IBooking,
|
||||
"paymentDeadline" | "paymentDrainEndsAt"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Classify a booking's pay window against `now`.
|
||||
*
|
||||
* Falls back to the raw deadline when the server sent no `paymentDrainEndsAt`
|
||||
* (older payload): with no known tail there is no drain to wait out, so the
|
||||
* window goes straight from open to closed.
|
||||
*/
|
||||
export function payWindowState(
|
||||
booking: PayableBooking | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): PayWindowState {
|
||||
const deadline = booking?.paymentDeadline ?? null;
|
||||
if (!deadline) {
|
||||
return { phase: "none", canPay: true, drainEndsAt: null };
|
||||
}
|
||||
|
||||
const deadlineMs = new Date(deadline).getTime();
|
||||
if (!Number.isFinite(deadlineMs)) {
|
||||
return { phase: "none", canPay: true, drainEndsAt: null };
|
||||
}
|
||||
if (now < deadlineMs) {
|
||||
return { phase: "open", canPay: true, drainEndsAt: null };
|
||||
}
|
||||
|
||||
const drainRaw = booking?.paymentDrainEndsAt ?? null;
|
||||
const drainMs = drainRaw ? new Date(drainRaw).getTime() : NaN;
|
||||
if (Number.isFinite(drainMs) && now < drainMs) {
|
||||
return { phase: "draining", canPay: false, drainEndsAt: drainRaw };
|
||||
}
|
||||
|
||||
return { phase: "closed", canPay: false, drainEndsAt: null };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -717,6 +717,12 @@ export interface IBooking extends BaseEntity {
|
||||
selectedForBatchAt?: string | null;
|
||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||
paymentDeadline?: string | null;
|
||||
/**
|
||||
* End of the settlement drain tail that follows `paymentDeadline`. Between the
|
||||
* two, an in-flight payment can still land, so the customer is shown a
|
||||
* "payment processing" state instead of a pay action.
|
||||
*/
|
||||
paymentDrainEndsAt?: string | null;
|
||||
|
||||
containers?: Array<{ type: string; qty: number; vgm: number }> | null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user