diff --git a/apps/edr-freight-api/src/migrations/3530000000000-ShippingLineInvoiceApprovals.ts b/apps/edr-freight-api/src/migrations/3530000000000-ShippingLineInvoiceApprovals.ts new file mode 100644 index 000000000..4183be8ff --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3530000000000-ShippingLineInvoiceApprovals.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Maker–checker for manual actions on shipping-line credit invoices. + * + * A shipping-line credit invoice is normally settled by the CBE webhook. Two + * manual paths exist for finance: recording an offline payment (MARK_PAID) + * and voiding an invoice raised in error (CANCEL, which releases its credits + * back to the unbilled pool). Both erase or move real debt, so neither is a + * single-person action: one permission raises the request, a different + * permission — held by a chief, and never the requester themselves — approves + * or rejects it. Rows are never deleted; decided requests are the audit trail. + * + * One PENDING row per invoice at a time (partial unique index): a second + * request while one is undecided is a coordination failure, not a workflow. + */ +export class ShippingLineInvoiceApprovals3530000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_invoice_approvals_action_enum + AS ENUM ('MARK_PAID', 'CANCEL'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_invoice_approvals_status_enum + AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.shipping_line_invoice_approvals ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + invoice_id uuid NOT NULL REFERENCES freight.invoices (id), + action freight.shipping_line_invoice_approvals_action_enum NOT NULL, + status freight.shipping_line_invoice_approvals_status_enum NOT NULL DEFAULT 'PENDING', + requested_by uuid NOT NULL, + reason varchar(500) NOT NULL, + payment_reference varchar(255), + decided_by uuid, + decided_at timestamptz, + decision_note varchar(500), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_sl_invoice_approvals_invoice_status + ON freight.shipping_line_invoice_approvals (invoice_id, status) + `); + + // The workflow invariant, enforced where it cannot race: at most one + // undecided request per invoice. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_sl_invoice_approvals_one_pending + ON freight.shipping_line_invoice_approvals (invoice_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.shipping_line_invoice_approvals`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_status_enum`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_action_enum`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 6752b37c1..bc79f8899 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -13,6 +13,9 @@ import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +// Entity-only import (no module edge): portal reads resolve shipping-line +// payers straight off the table. +import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { FilesService } from "../files/files.service"; @@ -574,23 +577,61 @@ export class BillingService { }); } - /** Invoices for the signed-in customer; empty when they have no company. */ + /** + * Resolve a shipping-line company from the signed-in user (null for ordinary + * customers). Queried straight off the entity rather than through + * ShippingLineCompaniesService — that module already imports billing, so a + * service edge back would deepen the forwardRef cycle for one lookup. + */ + private async resolveShippingLineCompanyId( + userId: string, + ): Promise { + const line = await this.dataSource + .getRepository(ShippingLineCompany) + .findOne({ where: { userId } }); + return line?.id ?? null; + } + + /** + * Invoices for the signed-in portal user; empty when they have no company. + * A payer is either a customer company or a shipping line (enforced by the + * DB's single-payer check), so the two lookups cannot both match. + */ async findForUser( userId: string, filter: { source?: string; sourceId?: string } = {}, ): Promise { const companyId = await this.resolveCompanyId(userId); - return companyId ? this.findByCompany(companyId, filter) : []; + if (companyId) return this.findByCompany(companyId, filter); + + const shippingLineCompanyId = + await this.resolveShippingLineCompanyId(userId); + if (!shippingLineCompanyId) return []; + return this.invoices.findAll({ + where: { + shippingLineCompanyId, + ...(filter.source ? { source: filter.source } : {}), + ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), + }, + order: { createdAt: "DESC" }, + }); } - /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ + /** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */ async findByIdForUser( id: string, userId: string, ): Promise { - const companyId = await this.resolveCompanyId(userId); const invoice = await this.findById(id); - if (!companyId || invoice.companyId !== companyId) { + const ownedByCompany = + invoice.companyId != null && + invoice.companyId === (await this.resolveCompanyId(userId)); + const ownedByShippingLine = + !ownedByCompany && + invoice.shippingLineCompanyId != null && + invoice.shippingLineCompanyId === + (await this.resolveShippingLineCompanyId(userId)); + if (!ownedByCompany && !ownedByShippingLine) { throw new NotFoundException(`Invoice ${id} not found`); } return invoice; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index eff15ec3d..01f0fd053 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -11,6 +11,7 @@ import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; +import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** @@ -58,10 +59,16 @@ export class BookingLifecycleNotifierService { // Both channels come from the same resolver: the company row's own columns // are only half the story (see companyNotifyEmailExpr), and reading them off // the loaded entity silently dropped every mail to a company whose address - // lives in `attributes`. - const { phone, email } = b.companyId - ? await resolveCompanyNotifyContact(this.dataSource, b.companyId) - : { phone: null, email: null }; + // lives in `attributes`. A shipping-line booking has NO company — its + // contact lives on the shipping_line_companies row itself. + const { phone, email } = b.shippingLineCompanyId + ? await resolveShippingLineNotifyTarget( + this.dataSource, + b.shippingLineCompanyId, + ) + : b.companyId + ? await resolveCompanyNotifyContact(this.dataSource, b.companyId) + : { phone: null, email: null }; if (phone) { try { @@ -82,13 +89,44 @@ export class BookingLifecycleNotifierService { } } - /** Persist + push an in-app item to all portal users of the booking's company. */ + /** + * Persist + push an in-app item to the booking's portal owner: every portal + * user of the company, or — for a shipping-line booking — the line's own + * account, deep-linked into the shipping-line app rather than the customer + * one (its routes live under /shipping-line/*). + */ private inApp( b: Booking, title: string, body: string, overrides: Partial = {}, ): void { + if (b.shippingLineCompanyId) { + void (async () => { + const { userId } = await resolveShippingLineNotifyTarget( + this.dataSource, + b.shippingLineCompanyId!, + ); + if (!userId) return; + void this.inbox.notify({ + recipients: { userIds: [userId] }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title, + body, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + // After the spread: overrides carry customer links — the bell must + // land a shipping line on ITS booking page. + link: `/shipping-line/bookings/${b.id}`, + }); + })().catch((err) => + this.logger.warn( + `shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`, + ), + ); + return; + } if (!b.companyId) return; // government/unlinked bookings have no portal users void this.inbox.notify({ recipients: { companyId: b.companyId }, @@ -176,13 +214,23 @@ export class BookingLifecycleNotifierService { /** Document approval finalized → customer can proceed to request operation. */ clearanceReady(b: Booking): void { - const msg = - `Document approval for booking ${b.reference} is finalized. ` + - `You can now proceed to request operation from the portal.`; + // A shipping line's next move is BOOKING (cargo + shipment day), not the + // customer's operation-request step — say so, or the message points at a + // flow their portal does not have. + const msg = b.shippingLineCompanyId + ? `Documents for booking ${b.reference} are approved. ` + + `You can now book your shipment — enter the cargo and shipment day from the portal.` + : `Document approval for booking ${b.reference} is finalized. ` + + `You can now proceed to request operation from the portal.`; void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED'); - this.inApp(b, 'Document approval finalized', msg, { - type: NotificationType.CLEARANCE_DECISION, - }); + this.inApp( + b, + b.shippingLineCompanyId + ? 'Documents approved — book your shipment' + : 'Document approval finalized', + msg, + { type: NotificationType.CLEARANCE_DECISION }, + ); } /** Intercity documents approved → booking waits in the ride-along pool. */ @@ -234,11 +282,19 @@ export class BookingLifecycleNotifierService { /** Operation accepted → invoice ready; await payment / booking window. */ operationAccepted(b: Booking): void { - const msg = - `Your operation request for booking ${b.reference} has been accepted. ` + - `An invoice has been prepared — watch for the payment window to secure your slot.`; + // No invoice and no pay window for a shipping line — the charge sits on + // its credit account and the booking boards its dedicated train directly. + const msg = b.shippingLineCompanyId + ? `Your booking ${b.reference} has been accepted. The charge has been ` + + `recorded on your credit account and your shipment is being placed on its train.` + : `Your operation request for booking ${b.reference} has been accepted. ` + + `An invoice has been prepared — watch for the payment window to secure your slot.`; void this.notifyContact(b, msg, 'OPERATION ACCEPTED'); - this.inApp(b, 'Operation request accepted', msg); + this.inApp( + b, + b.shippingLineCompanyId ? 'Booking accepted' : 'Operation request accepted', + msg, + ); } /** diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 944e3db15..2f50d0f38 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -39,6 +39,8 @@ import { ClearanceWorkflowService } from '../contracts/clearance-workflow.servic import { ContractDocPhase } from '@edr/types'; import { BookingInvoiceService } from "./booking-invoice.service"; +// Type-only: the DI edge stays event-based to keep the module graph acyclic. +import type { ShippingLineBookingAcceptedPayload } from "../shipping-lines/shipping-line-credits.service"; @Injectable() export class BookingTransitionService { @@ -1012,7 +1014,14 @@ export class BookingTransitionService { // The customer's train pick only exists for export rail; it rides the // booking through the space checks below AND is persisted so the accept / // reserve path locks onto that train (pickExportSchedule honors it). - const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null; + // Shipping-line completions (bypassDayPool) pick among the line's own + // dedicated trains — already validated by the caller, so the pick is + // persisted here the same way an export pick is. Customer import/domestic + // bookings still never carry one (the batch engine assigns their train). + const requestedId = + isExportTrain || opts?.bypassDayPool + ? (requestedTrainScheduleId ?? null) + : null; // Export rail rides the exact train the customer picked — never an // auto-assigned one. Both portal flows (clearance + contract completion) // surface a picker, so a missing id is an invalid submission, not a @@ -1216,10 +1225,22 @@ export class BookingTransitionService { // booking page correctly still showed it as not payable. The batch engine // issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window // and the real deadline are created — matching the portal's `canPay` gate. - const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); - this.logger.log( - `Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`, - ); + // + // Shipping-line bookings mint NO invoice at all: they have no company row + // to bill (the invoices FK requires one) and they pay on the credit ledger + // — the charge was recorded at completion, and Finance bills a batch of + // credits later through ShippingLineCreditsService.generateInvoice. + if (booking.shippingLineCompanyId) { + this.logger.log( + `Skipping invoice for shipping-line booking ${booking.reference}:${booking.id} — billed later from the credit ledger`, + ); + } else { + const invoice = + await this.invoiceService.ensureInvoiceForBooking(booking); + this.logger.log( + `Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`, + ); + } // TODO: road (truck) orders are an incomplete feature — they stop at the // dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no // per-km pricing wired via roadKmPrice, no pay surface in the portal). They @@ -1233,6 +1254,7 @@ export class BookingTransitionService { lockedAt: booking.lockedAt ?? now, } as never); const roadFresh = await this.bookingsService.findById(booking.id); + this.emitShippingLineAccepted(roadFresh); this.notifier.operationAccepted(roadFresh); return roadFresh; } @@ -1269,11 +1291,44 @@ export class BookingTransitionService { // batch runs after the window closes + staff document review, never at accept // time. (Legacy pre-migration schedules with no window phase are still served // by the periodic legacy fill.) + // + // EXCEPT shipping-line bookings: they pay later on the credit ledger, so + // no pay window exists to wait for — accept places them straight onto + // their company's dedicated train and its wagons. Non-fatal on purpose: + // the accept has committed; an allocation hiccup leaves the booking in + // the day pool for the batch engine / staff instead of failing the accept. + if (booking.shippingLineCompanyId) { + try { + await this.bookingBatchService.allocateShippingLineAccepted(booking.id); + } catch (err) { + this.logger.warn( + `Auto-allocation failed for shipping-line booking ${booking.reference}:${booking.id} — left in the day pool: ${(err as Error).message}`, + ); + } + } const trainFresh = await this.bookingsService.findById(booking.id); + this.emitShippingLineAccepted(trainFresh); this.notifier.operationAccepted(trainFresh); return trainFresh; } + /** + * A shipping-line booking becomes debt at THIS moment — Operations accepted + * it — not at completion/pricing. Event, not a service call: + * ShippingLineCreditsService listens (`shipping_line_booking.accepted`), and + * importing its module here would close a module cycle. Emitted after the + * accept has fully committed (including the export-capacity path, which can + * still revert the status above), so a failed accept never creates debt. + */ + private emitShippingLineAccepted(booking: Booking): void { + if (!booking.shippingLineCompanyId) return; + this.events.emit("shipping_line_booking.accepted", { + bookingId: booking.id, + reference: booking.reference, + amount: Number(booking.totalAmount), + } satisfies ShippingLineBookingAcceptedPayload); + } + async enrichBookingResponse(booking: Booking): Promise< Booking & { latestChangeRequestNote?: string | null; diff --git a/apps/edr-freight-api/src/modules/notifications/resolve-shipping-line-contact.util.ts b/apps/edr-freight-api/src/modules/notifications/resolve-shipping-line-contact.util.ts new file mode 100644 index 000000000..d736cdf48 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/resolve-shipping-line-contact.util.ts @@ -0,0 +1,33 @@ +import { DataSource } from "typeorm"; + +import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; + +/** + * Notification target for a shipping-line booking. + * + * A shipping line is NOT a `companies` row: the company IS the account — one + * IAM user (`userId`), and the contact details live on the + * `shipping_line_companies` row itself. So the customer resolvers + * (external_profiles fan-out, company attributes email) never apply; this is + * the one lookup every shipping-line notification routes through. + * + * Entity-only import — safe from any module graph: notifiers already own a + * DataSource and need no service from the shipping-lines module. + */ +export async function resolveShippingLineNotifyTarget( + dataSource: DataSource, + shippingLineCompanyId: string, +): Promise<{ + userId: string | null; + phone: string | null; + email: string | null; +}> { + const line = await dataSource + .getRepository(ShippingLineCompany) + .findOne({ where: { id: shippingLineCompanyId } }); + return { + userId: line?.userId ?? null, + phone: line?.phoneNumber ?? null, + email: line?.email ?? null, + }; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts index c46829841..90e31e42d 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Transform, Type } from "class-transformer"; import { IsArray, + IsBoolean, IsDateString, IsIn, IsInt, @@ -15,15 +16,61 @@ import { import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto"; +/** + * One physical container on a line — number, seal, VGM and its per-container + * handling switches. Same shape the customer shipment form submits. + */ +export class CompleteShippingLineContainerUnitDto { + @ApiProperty({ example: "MSCU1234567" }) + @IsString() + containerNumber!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sealNumber?: string; + + @ApiProperty({ minimum: 0, description: "VGM of this container, tons." }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + vgmTons!: number; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + isHazardous?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + isReefer?: boolean; +} + /** * One container line the shipping line ships — container type + count, the - * same shape the customer one-time form collects (no per-unit ISO numbers; - * those are captured downstream at yard operations, as for customers). + * same shape the customer one-time form collects. When `units` is sent (the + * full booking page), per-container numbers/seals/VGM and handling switches + * are persisted exactly like the customer shipment form; without it (legacy + * modal shape) the line-level counts stand alone. */ export class CompleteShippingLineContainerLineDto { - @ApiProperty({ format: "uuid", description: "Container type being shipped." }) + @ApiPropertyOptional({ + format: "uuid", + description: + "Container type being shipped. Optional when containerSize is sent — the server resolves the type from the size.", + }) + @IsOptional() @IsUUID() - containerTypeId!: string; + containerTypeId?: string; + + @ApiPropertyOptional({ + description: + 'Container size, e.g. "20ft" | "40ft". The server maps it to the configured container type (reefer variant when the line carries reefer boxes) — so the client never needs the type catalog.', + }) + @IsOptional() + @IsString() + containerSize?: string; @ApiProperty({ minimum: 1 }) @IsInt() @@ -51,6 +98,17 @@ export class CompleteShippingLineContainerLineDto { @Min(0) @Transform(({ value }) => Number(value)) reeferQuantity?: number; + + @ApiPropertyOptional({ + type: [CompleteShippingLineContainerUnitDto], + description: + "Per-container details. When present, the handling counts and VGM are derived from these rows.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CompleteShippingLineContainerUnitDto) + units?: CompleteShippingLineContainerUnitDto[]; } /** @@ -66,6 +124,15 @@ export class CompleteShippingLineBookingDto { @IsDateString() scheduledDate!: string; + @ApiPropertyOptional({ + format: "uuid", + description: + "Which of the line's dedicated trains this booking rides. Required when more than one departs on the chosen day; implicit with a single departure.", + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ enum: PAYMENT_CURRENCIES }) @IsOptional() @IsIn([...PAYMENT_CURRENCIES]) @@ -99,6 +166,26 @@ export class CompleteShippingLineBookingDto { @Transform(({ value }) => Number(value)) cargoWeightTons?: number; + @ApiPropertyOptional({ + minimum: 0, + description: "Bulk freight: hazardous portion of the cargo.", + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + bulkHazardousQuantity?: number; + + @ApiPropertyOptional({ + minimum: 0, + description: "Bulk freight: refrigerated portion of the cargo.", + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + bulkReeferQuantity?: number; + @ApiPropertyOptional({ description: "What the containers carry." }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts index 4e659e317..7ad524f01 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts @@ -38,6 +38,40 @@ export class GenerateCreditInvoiceDto { dueInDays?: number; } +/** Finance's request for a manual action on a credit invoice (maker step). */ +export class RequestInvoiceActionDto { + @ApiProperty({ + description: + "Why the action is needed. Shown to the approver and kept for audit.", + example: "Paid by bank transfer, slip #TT-4491", + }) + @IsString() + @MinLength(3) + @MaxLength(500) + reason!: string; + + @ApiPropertyOptional({ + description: + "Offline payment reference (bank slip / transfer number). MARK_PAID requests only.", + example: "TT-4491", + }) + @IsOptional() + @IsString() + @MaxLength(255) + paymentReference?: string; +} + +/** The decision on a pending request (approve and reject routes). */ +export class DecideInvoiceActionDto { + @ApiPropertyOptional({ + description: "Decision note. Required when rejecting.", + }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} + /** Write-off of a single unbilled credit. */ export class CancelCreditDto { @ApiProperty({ diff --git a/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-invoice-approval.entity.ts b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-invoice-approval.entity.ts new file mode 100644 index 000000000..54ef7338d --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-invoice-approval.entity.ts @@ -0,0 +1,82 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { Invoice } from "../../billing/entities/invoice.entity"; + +/** What finance asked to do to a shipping-line credit invoice. */ +export enum ShippingLineInvoiceActionType { + /** Record a full offline settlement (paid outside the gateway). */ + MarkPaid = "MARK_PAID", + /** Void the invoice; its credits return to the unbilled pool. */ + Cancel = "CANCEL", +} + +export enum ShippingLineInvoiceActionStatus { + Pending = "PENDING", + Approved = "APPROVED", + Rejected = "REJECTED", +} + +/** + * Maker–checker for manual actions on shipping-line credit invoices. + * + * Marking an invoice paid by hand erases real debt, and cancelling one + * releases its credits back to the unbilled pool — either done unilaterally is + * a one-person fraud path. So finance REQUESTS the action (one permission) + * and a chief APPROVES or REJECTS it (a separate permission, different + * person). Every request is kept, decided or not: the table is the audit + * trail of who asked, who decided, and why. + */ +@Entity({ schema: "freight", name: "shipping_line_invoice_approvals" }) +@Index(["invoiceId", "status"]) +export class ShippingLineInvoiceApproval extends BaseEntity { + @Column({ name: "invoice_id", type: "uuid" }) + invoiceId!: string; + + @ManyToOne(() => Invoice) + @JoinColumn({ name: "invoice_id" }) + invoice?: Invoice; + + @Column({ name: "action", type: "enum", enum: ShippingLineInvoiceActionType }) + action!: ShippingLineInvoiceActionType; + + @Column({ + name: "status", + type: "enum", + enum: ShippingLineInvoiceActionStatus, + default: ShippingLineInvoiceActionStatus.Pending, + }) + status!: ShippingLineInvoiceActionStatus; + + /** IAM user id of the finance staff who raised the request. */ + @Column({ name: "requested_by", type: "uuid" }) + requestedBy!: string; + + /** Why the action is needed; shown to the approver, kept for audit. */ + @Column({ name: "reason", type: "varchar", length: 500 }) + reason!: string; + + /** Offline payment reference (bank slip no. etc.) for MARK_PAID requests. */ + @Column({ + name: "payment_reference", + type: "varchar", + length: 255, + nullable: true, + }) + paymentReference?: string | null; + + /** IAM user id of the chief who approved/rejected; null while pending. */ + @Column({ name: "decided_by", type: "uuid", nullable: true }) + decidedBy?: string | null; + + @Column({ name: "decided_at", type: "timestamptz", nullable: true }) + decidedAt?: Date | null; + + @Column({ + name: "decision_note", + type: "varchar", + length: 500, + nullable: true, + }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts index 155988a09..b2417a08f 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts @@ -1,5 +1,13 @@ import { CurrentUser } from "@edr/api-common"; -import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { PortalCustomer } from "../../common/booking-guards"; @@ -37,6 +45,54 @@ export class ShippingLineBookingCompletionController { return this.completionService.availableDaysMine(user.id, id); } + @Get(":id/trains") + @PortalCustomer() + @ApiOperation({ + summary: + "The line's dedicated trains on the booking's lane for a shipment day, each with per-wagon-type free space — for the completion form's train picker. Cargo context (sizes/cargoTypeId/wagons) refines the availability.", + }) + async trainsForDay( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Query("date") date?: string, + @Query("sizes") sizes?: string, + @Query("cargoTypeId") cargoTypeId?: string, + @Query("wagons") wagons?: string, + ) { + return this.completionService.trainsForDayMine(user.id, id, date, { + containerSizes: sizes ? sizes.split(",").filter(Boolean) : undefined, + cargoTypeId: cargoTypeId || undefined, + wagons: wagons ? Number(wagons) : undefined, + }); + } + + @Post(":id/price-preview") + @PortalCustomer() + @ApiOperation({ + summary: + "Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", + }) + async pricePreview( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CompleteShippingLineBookingDto, + ) { + return this.completionService.previewPriceMine(user.id, id, dto); + } + + @Get(":id/operations") + @PortalCustomer() + @ApiOperation({ + summary: + "Operations view of the booking: the train it rides (assigned or requested) and the wagons allocated to it.", + }) + async operations( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.completionService.operationsMine(user.id, id); + } + @Post(":id/complete") @PortalCustomer() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts index a7c938380..0f7be3f18 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts @@ -11,12 +11,18 @@ import { BookingPricingService } from "../bookings/booking-pricing.service"; import { BookingTransitionService } from "../bookings/booking-transition.service"; import { BookingsService } from "../bookings/bookings.service"; import { BookingContainer } from "../bookings/entities/booking-container.entity"; +import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity"; import { Booking } from "../bookings/entities/booking.entity"; import { wagonsPerUnitForSize } from "../rule-engine/container-type.util"; import { CargoType } from "../rule-engine/entities/cargo-type.entity"; import { ContainerType } from "../rule-engine/entities/container-type.entity"; import { eatDay } from "../train-scheduling/batch-window.util"; +import { + BookingBatchService, + type TrainOptionCargoOverrides, +} from "../train-scheduling/booking-batch.service"; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity"; import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service"; import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto"; import { @@ -49,6 +55,7 @@ export class ShippingLineBookingCompletionService { private readonly bookingPricingService: BookingPricingService, private readonly bookingTransitionService: BookingTransitionService, private readonly trainSchedulingService: TrainSchedulingService, + private readonly bookingBatchService: BookingBatchService, private readonly creditsService: ShippingLineCreditsService, ) {} @@ -108,6 +115,28 @@ export class ShippingLineBookingCompletionService { return closesAt.getTime() > Date.now(); } + /** + * The line's dedicated trains on the booking's lane for one shipment day, + * each with per-wagon-type free space — the completion form's train picker. + * A booking rides ONE schedule, so with several departures that day the + * line picks which; the pick is validated again at complete time. + */ + async trainsForDayMine( + userId: string, + bookingId: string, + date: string | undefined, + overrides?: TrainOptionCargoOverrides, + ) { + const booking = await this.requireOwnBooking(userId, bookingId); + if (!booking.shippingLineCompanyId) return []; + return this.bookingBatchService.dedicatedTrainOptionsForDay( + booking, + date ? eatDay(new Date(date)) : null, + booking.shippingLineCompanyId, + overrides, + ); + } + /** * Days the shipping line may pick as the shipment day. * @@ -174,12 +203,32 @@ export class ShippingLineBookingCompletionService { (s) => eatDay(s.scheduledDepartureDate) === pickedDay, ); let bypassDayPool = false; + let requestedTrainScheduleId: string | null = null; if (dedicatedOnDay.length > 0) { - if (!dedicatedOnDay.some((s) => this.isStillOpen(s))) { + const openOnDay = dedicatedOnDay.filter((s) => this.isStillOpen(s)); + if (openOnDay.length === 0) { throw new BadRequestException( "Booking for your train on this day has closed — the cut-off before departure has passed.", ); } + // A booking rides ONE schedule. Several departures that day → the line + // must say which; a single one is picked implicitly. The id comes from + // the request, so it is validated against the day's own trains. + if (dto.trainScheduleId) { + const picked = openOnDay.find((s) => s.id === dto.trainScheduleId); + if (!picked) { + throw new BadRequestException( + "The selected train does not run your route on that day (or its booking cut-off has passed) — pick another train.", + ); + } + requestedTrainScheduleId = picked.id; + } else if (openOnDay.length === 1) { + requestedTrainScheduleId = openOnDay[0].id; + } else { + throw new BadRequestException( + "More than one of your trains departs that day — select which train this booking rides.", + ); + } // The day is backed by the line's own train, which every customer pool // deliberately excludes — so the day-pool gate downstream must not run. bypassDayPool = true; @@ -260,14 +309,35 @@ export class ShippingLineBookingCompletionService { booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0, bulkTotalWeightTons: booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null, + // Bulk handling portions — sized against the cargo, billed by pricing. + ...(booking.freightType === "BULK" + ? { + bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0), + bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0), + } + : {}), // Hazard is per-line for containers; the booking-level flag is what // pricing bills the surcharge from. - isHazardous: (dto.containers ?? []).some( - (line) => Number(line.hazardousQuantity ?? 0) > 0, - ), - // Completion fixes the cargo — and therefore the price — so it is also - // where the billing currency is chosen. - paymentCurrency: dto.paymentCurrency ?? booking.paymentCurrency, + isHazardous: + (dto.containers ?? []).some( + (line) => + Number(line.hazardousQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isHazardous), + ) || Number(dto.bulkHazardousQuantity ?? 0) > 0, + // Same for reefer: the rule engine's REEFER trigger fires on the + // booking-level flag (or a reefer container TYPE) — a ticked reefer + // switch on a standard box only sets the per-line count, so without + // this flag the surcharge silently never bills. + isReefer: + (dto.containers ?? []).some( + (line) => + Number(line.reeferQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isReefer), + ) || Number(dto.bulkReeferQuantity ?? 0) > 0, + // Shipping lines are always billed in ETB: the charge lands on the + // ETB credit ledger, so the currency is enforced here rather than + // trusted from the payload. + paymentCurrency: "ETB", } as never); const loaded = await this.bookingsRepository.findOne({ @@ -305,14 +375,10 @@ export class ShippingLineBookingCompletionService { computed.appliedModifiers, ); - // The charge goes on the line's credit ledger ("use now, pay later") — - // idempotent per booking, so a retried completion cannot double the debt. - await this.creditsService.recordCredit({ - bookingId, - amount: computed.totalAmount, - currency: computed.currency, - description: `Freight service — booking ${booking.reference}`, - }); + // No credit is recorded here: completion only REQUESTS the operation. + // The charge lands on the line's ledger when Operations accepts — + // `shipping_line_booking.accepted` → ShippingLineCreditsService — so a + // request that is returned or never accepted creates no debt. } // Binding day, OPERATION_REQUEST_PENDING and the staff notification — the @@ -321,11 +387,307 @@ export class ShippingLineBookingCompletionService { return this.bookingTransitionService.requestOperation( bookingId, dto.scheduledDate, - null, + requestedTrainScheduleId, bypassDayPool ? { bypassDayPool: true } : undefined, ); } + /** + * Authoritative price preview for the completion form's confirm step: the + * SAME compute the completion itself runs, over an in-memory probe shaped + * exactly like completeMine would persist the booking — so the figure the + * shipping line confirms is line-for-line what it will owe. + * + * The result is not advisory-only: the breakdown is saved on the booking and + * the rate snapshots are (re)written, so every re-preview refreshes them. + * Nothing else is persisted — no cargo rows, no credit, no transition. + */ + async previewPriceMine( + userId: string, + bookingId: string, + dto: CompleteShippingLineBookingDto, + ) { + const booking = await this.requireOwnBooking(userId, bookingId, { + bookingContainers: true, + }); + if ( + !["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes( + booking.status, + ) + ) { + throw new BadRequestException( + "Your documents must be approved before the booking can be priced.", + ); + } + + // In-memory cargo, mirroring what completeMine persists. + let probeContainers: Partial[] = []; + let bulkFields: Record = {}; + if (booking.freightType === "CONTAINER") { + const lines = dto.containers ?? []; + if (!lines.length) { + throw new BadRequestException( + "At least one container line is required.", + ); + } + for (const line of lines) { + const containerType = await this.resolveContainerType(line); + const figures = this.lineFigures(line); + probeContainers.push({ + containerTypeId: containerType.id, + containerSize: containerType.sizeFt + ? `${containerType.sizeFt}ft` + : null, + quantity: line.quantity, + hazardousQuantity: figures.hazardous, + reeferQuantity: figures.reefer, + returnQuantity: 0, + vgmPerUnitTons: figures.vgmPerUnit, + totalVgmTons: figures.totalVgm, + wagonsRequired: Math.ceil( + line.quantity * wagonsPerUnitForSize(containerType.sizeFt), + ), + }); + } + } else { + if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) { + throw new BadRequestException( + "Bulk bookings need a cargo type and a total weight in tons.", + ); + } + const cargoType = await this.bookingsRepository.manager + .getRepository(CargoType) + .findOne({ where: { id: dto.cargoTypeId, isActive: true } }); + if (!cargoType) { + throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + bulkFields = { + cargoTypeId: dto.cargoTypeId, + cargoTotalWeightVgm: Number(dto.cargoWeightTons), + bulkTotalWeightTons: Number(dto.cargoWeightTons), + bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0), + bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0), + }; + probeContainers = []; + } + + // Prototype-preserving clone so entity getters keep working — the same + // probe trick the contract preview uses. + const probe = Object.assign( + Object.create(Object.getPrototypeOf(booking)), + booking, + { + bookingContainers: probeContainers, + paymentCurrency: "ETB", + isHazardous: + (dto.containers ?? []).some( + (line) => + Number(line.hazardousQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isHazardous), + ) || Number(dto.bulkHazardousQuantity ?? 0) > 0, + // Mirrors completeMine: without the booking-level flag the engine's + // REEFER trigger never fires for reefer opt-ins on standard boxes, + // and the quote would show base freight only. + isReefer: + (dto.containers ?? []).some( + (line) => + Number(line.reeferQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isReefer), + ) || Number(dto.bulkReeferQuantity ?? 0) > 0, + ...bulkFields, + }, + ) as Booking; + + const computed = + await this.bookingPricingService.computePriceForBooking(probe); + if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { + throw new BadRequestException( + computed.hardBlocked.length > 0 + ? computed.hardBlocked.join("; ") + : "No rate is configured for your shipping line on this route/cargo — please contact Operations.", + ); + } + + // Persist the quoted figure: breakdown on the booking, snapshots of the + // rates it was built from. createPricingSnapshots clears the previous + // artifacts first, so a re-preview replaces the old quote rather than + // stacking a second one. + await this.bookingsRepository.update(bookingId, { + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + return { + totalAmount: computed.totalAmount, + currency: computed.currency, + lineItems: computed.lineItems, + warnings: computed.warnings, + }; + } + + /** + * What operations has done with the booking so far: the train it rides + * (assigned, or the requested one before assignment) and the wagons the + * batch engine allocated to it, with any container numbers loaded per wagon. + * Read-only, owner-scoped — feeds the detail page's Wagons & Train tab. + */ + async operationsMine(userId: string, bookingId: string) { + const booking = await this.requireOwnBooking(userId, bookingId); + const manager = this.bookingsRepository.manager; + + const scheduleId = + booking.trainScheduleId ?? booking.requestedTrainScheduleId ?? null; + let train: Record | null = null; + if (scheduleId) { + const schedule = await manager.getRepository(TrainSchedule).findOne({ + where: { id: scheduleId }, + relations: { originStation: true, destinationStation: true }, + }); + if (schedule) { + train = { + id: schedule.id, + reference: schedule.reference, + trainNumber: schedule.trainNumber, + status: schedule.status, + direction: schedule.direction, + scheduledDepartureDate: schedule.scheduledDepartureDate, + scheduledArrivalDate: schedule.scheduledArrivalDate, + originLabel: + schedule.originStation?.label ?? + schedule.originStation?.code ?? + "Origin", + destinationLabel: + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + "Destination", + // Whether this is the confirmed assignment or still the request. + assigned: Boolean(booking.trainScheduleId), + }; + } + } + + const allocations = await manager + .getRepository(WagonBookingAllocation) + .find({ + where: { bookingId }, + relations: { + trainSetWagon: { wagonType: true, physicalWagon: true }, + containerItems: true, + }, + order: { createdAt: "ASC" }, + }); + + const wagons = allocations.map((allocation) => ({ + id: allocation.id, + status: allocation.status, + loadType: allocation.loadType, + allocatedWeightTons: Number(allocation.allocatedWeightTons), + sequenceNo: allocation.trainSetWagon?.sequenceNo ?? null, + wagonNumber: allocation.trainSetWagon?.physicalWagon?.wagonNumber ?? null, + wagonType: + allocation.trainSetWagon?.wagonType?.name ?? + allocation.trainSetWagon?.wagonType?.code ?? + null, + capacityTons: Number(allocation.trainSetWagon?.capacityTons ?? 0), + containerNumbers: (allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter((n): n is string => Boolean(n)), + })); + + return { train, wagons }; + } + + /** + * Resolve a line's container type: by id when the payload carries one, else + * from the size string ("40ft" → the active 40ft type, preferring the reefer + * variant when the line ships reefer boxes). Resolution lives HERE, not in + * the portal, so a slow or failed catalog fetch can never block a booking + * with a phantom "type not configured" error — mirrors the customer flow's + * server-side size→type mapping. + */ + private async resolveContainerType(line: { + containerTypeId?: string; + containerSize?: string; + reeferQuantity?: number; + units?: { isReefer?: boolean }[]; + }): Promise { + const containerTypeRepo = + this.bookingsRepository.manager.getRepository(ContainerType); + + if (line.containerTypeId) { + const byId = await containerTypeRepo.findOne({ + where: { id: line.containerTypeId, isActive: true }, + }); + if (!byId) { + throw new NotFoundException( + `Container type ${line.containerTypeId} not found`, + ); + } + return byId; + } + + const sizeFt = parseInt(line.containerSize ?? "", 10); + if (!Number.isFinite(sizeFt)) { + throw new BadRequestException( + "Each container line needs a containerTypeId or a containerSize.", + ); + } + const candidates = await containerTypeRepo.find({ + where: { isActive: true }, + }); + const ofSize = candidates.filter((ct) => Number(ct.sizeFt) === sizeFt); + if (!ofSize.length) { + throw new BadRequestException( + `No ${sizeFt}ft container type is configured — please contact Operations.`, + ); + } + const wantsReefer = + Number(line.reeferQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isReefer); + if (wantsReefer) { + const reefer = ofSize.find((ct) => ct.isReefer); + if (reefer) return reefer; + } + return ofSize.find((ct) => !ct.isReefer) ?? ofSize[0]; + } + + /** + * A line's derived figures. With per-container rows (the full booking page), + * counts and VGM come FROM the rows — each container's switches are the + * source of truth. Without them, the line-level figures stand alone. + */ + private lineFigures(line: { + quantity: number; + vgmPerUnitTons?: number; + hazardousQuantity?: number; + reeferQuantity?: number; + units?: { vgmTons?: number; isHazardous?: boolean; isReefer?: boolean }[]; + }) { + const units = line.units ?? []; + const hazardous = units.length + ? units.filter((u) => u.isHazardous).length + : Math.min(Number(line.hazardousQuantity ?? 0), line.quantity); + const reefer = units.length + ? units.filter((u) => u.isReefer).length + : Math.min(Number(line.reeferQuantity ?? 0), line.quantity); + const totalVgm = units.length + ? units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) + : Number(line.vgmPerUnitTons ?? 0) * line.quantity; + const vgmPerUnit = units.length + ? totalVgm / units.length + : Number(line.vgmPerUnitTons ?? 0); + return { hazardous, reefer, totalVgm, vgmPerUnit }; + } + /** * Persist the container lines of a CONTAINER completion. Same row shape the * customer paths write (quantity per type, VGM totals, wagon share) — the @@ -341,27 +703,18 @@ export class ShippingLineBookingCompletionService { throw new BadRequestException("At least one container line is required."); } - const containerTypeRepo = - this.bookingsRepository.manager.getRepository(ContainerType); const containerRepo = this.bookingsRepository.manager.getRepository(BookingContainer); + const unitRepo = + this.bookingsRepository.manager.getRepository(BookingContainerUnit); for (const line of lines) { - const containerType = await containerTypeRepo.findOne({ - where: { id: line.containerTypeId, isActive: true }, - }); - if (!containerType) { - throw new NotFoundException( - `Container type ${line.containerTypeId} not found`, - ); - } - const hazardous = Math.min( - Number(line.hazardousQuantity ?? 0), - line.quantity, - ); - const reefer = Math.min(Number(line.reeferQuantity ?? 0), line.quantity); - const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0); - await containerRepo.save( + const containerType = await this.resolveContainerType(line); + // Counts and VGM derived by lineFigures — the same math the price + // preview runs, so the persisted cargo always matches the quote. + const units = line.units ?? []; + const figures = this.lineFigures(line); + const containerRow = await containerRepo.save( containerRepo.create({ bookingId: booking.id, containerTypeId: containerType.id, @@ -369,16 +722,31 @@ export class ShippingLineBookingCompletionService { ? `${containerType.sizeFt}ft` : null, quantity: line.quantity, - hazardousQuantity: hazardous, - reeferQuantity: reefer, + hazardousQuantity: figures.hazardous, + reeferQuantity: figures.reefer, returnQuantity: 0, - vgmPerUnitTons: vgmPerUnit, - totalVgmTons: vgmPerUnit * line.quantity, + vgmPerUnitTons: figures.vgmPerUnit, + totalVgmTons: figures.totalVgm, wagonsRequired: Math.ceil( line.quantity * wagonsPerUnitForSize(containerType.sizeFt), ), }), ); + let sortOrder = 0; + for (const unit of units) { + await unitRepo.save( + unitRepo.create({ + bookingContainerId: containerRow.id, + containerNumber: unit.containerNumber.trim().toUpperCase(), + sealNumber: unit.sealNumber?.trim() || null, + vgmTons: Number(unit.vgmTons ?? 0), + isHazardous: unit.isHazardous ?? false, + isReefer: unit.isReefer ?? false, + isReturn: false, + sortOrder: sortOrder++, + }), + ); + } } } diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts index b9656807d..da1b90129 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts @@ -319,8 +319,16 @@ export class ShippingLineBookingsService { const booking = await this.bookingsRepository.findOne({ where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, // Yards are loaded so the portal can render the lane without a second - // lookup — they are set at initiate time from the chosen route. - relations: { originYard: true, destinationYard: true, serviceType: true }, + // lookup — they are set at initiate time from the chosen route. Cargo + // (container lines + units, bulk cargo type) rides along for the detail + // page's cargo tab once the booking is completed. + relations: { + originYard: true, + destinationYard: true, + serviceType: true, + cargoType: true, + bookingContainers: { containerType: true, units: true }, + }, }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); @@ -330,7 +338,24 @@ export class ShippingLineBookingsService { .getRepository(BookingDocumentReview) .count({ where: { bookingId, status: "QUERIED" } }); - return { ...booking, hasQueriedDocuments: queriedCount > 0 }; + // The note Operations wrote when returning the request — the line has to + // read it to know what to fix. Only the latest CHANGES_REQUESTED note is + // exposed; the other review-note types are staff-internal. + const changeNote = + booking.status === "OPERATION_CHANGES_REQUESTED" + ? await this.bookingsRepository.manager + .getRepository(BookingReviewNote) + .findOne({ + where: { bookingId, type: "CHANGES_REQUESTED" }, + order: { createdAt: "DESC" }, + }) + : null; + + return { + ...booking, + hasQueriedDocuments: queriedCount > 0, + operationChangeNote: changeNote?.note ?? null, + }; } /** diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts index 15ba673a3..3d11e2a29 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts @@ -49,7 +49,12 @@ export class ShippingLineCompaniesController { } @Get() - @BookingStaff(FREIGHT_PERMS.shippingLines.view) + // OR'd: the credits view needs this list as its line picker, so holding + // shipping_line_credits:view alone is enough to read it. + @BookingStaff([ + FREIGHT_PERMS.shippingLines.view, + FREIGHT_PERMS.shippingLineCredits.view, + ]) @ApiOperation({ summary: "List shipping lines (paginated)" }) async list( @Query("page") page?: string, diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts index 698dfb384..888a996a6 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts @@ -9,6 +9,8 @@ import { Booking } from "../bookings/entities/booking.entity"; import { OtpModule } from "../otp/otp.module"; import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; import { ShippingLineCredit } from "./entities/shipping-line-credit.entity"; +import { ShippingLineInvoiceApproval } from "./entities/shipping-line-invoice-approval.entity"; +import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository"; import { ShippingLineBookingsController } from "./shipping-line-bookings.controller"; import { ShippingLineBookingsService } from "./shipping-line-bookings.service"; import { ShippingLineCompaniesController } from "./shipping-line-companies.controller"; @@ -25,6 +27,7 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service"; TypeOrmModule.forFeature([ ShippingLineCompany, ShippingLineCredit, + ShippingLineInvoiceApproval, User, Booking, ]), @@ -48,6 +51,7 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service"; ShippingLineBookingsService, ShippingLineCreditsService, ShippingLineCreditsRepository, + ShippingLineInvoiceApprovalsRepository, ], // Exported so whatever prices a shipping-line booking can record the charge. exports: [ShippingLineCompaniesService, ShippingLineCreditsService], diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts index 2ac618afe..7a2c8418f 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts @@ -1,4 +1,5 @@ import { CurrentUser } from "@edr/api-common"; +import { Freight } from "@edr/types"; import { Body, Controller, @@ -14,9 +15,12 @@ import { BookingStaff, PortalCustomer } from "../../common/booking-guards"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { CancelCreditDto, + DecideInvoiceActionDto, GenerateCreditInvoiceDto, + RequestInvoiceActionDto, } from "./dto/shipping-line-credit.dto"; import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity"; +import { ShippingLineInvoiceActionType } from "./entities/shipping-line-invoice-approval.entity"; import { ShippingLineCreditsService } from "./shipping-line-credits.service"; interface CurrentIamUser { @@ -37,6 +41,159 @@ interface CurrentIamUser { export class ShippingLineCreditsController { constructor(private readonly credits: ShippingLineCreditsService) {} + @Get() + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "The whole credit ledger across every shipping line (paginated), optionally filtered by line and/or status.", + }) + async listAll( + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("status") status?: ShippingLineCreditStatus, + @Query("shippingLineId", new ParseUUIDPipe({ optional: true })) + shippingLineId?: string, + ) { + return this.credits.listAll( + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + status, + shippingLineId, + ); + } + + // Declared before the parameterised staff routes so "summary" is never + // captured as a shipping-line id. + @Get("summary") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Outstanding totals across every shipping line, or one line when shippingLineId is given.", + }) + async summaryAll( + @Query("shippingLineId", new ParseUUIDPipe({ optional: true })) + shippingLineId?: string, + ) { + return this.credits.summary(shippingLineId); + } + + // Declared before ":shippingLineId" so "invoices" is never captured as an id. + @Get("invoices") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Credit invoices across every shipping line (paginated), each with any pending manual-action request.", + }) + async listInvoices( + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("status") status?: string, + @Query("shippingLineId", new ParseUUIDPipe({ optional: true })) + shippingLineId?: string, + ) { + return this.credits.listCreditInvoices( + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + status as Freight.InvoiceStatus | undefined, + shippingLineId, + ); + } + + @Get("invoice-actions/pending") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Undecided manual-action requests for a batch of invoices (one lookup for a list page).", + }) + async pendingInvoiceActions(@Query("invoiceIds") invoiceIds?: string) { + const ids = (invoiceIds ?? "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + return this.credits.pendingInvoiceActions(ids); + } + + // ── Maker–checker on credit invoices ────────────────────────────────────── + // Request and approve are DIFFERENT permissions, and the service refuses a + // decision by the requester — marking debt paid or voiding an invoice is + // never a one-person action. + + @Post("invoices/:invoiceId/mark-paid-request") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid) + @ApiOperation({ + summary: + "Request recording a full offline payment against a credit invoice (awaits chief approval).", + }) + async requestMarkPaid( + @Param("invoiceId", ParseUUIDPipe) invoiceId: string, + @Body() dto: RequestInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.requestInvoiceAction( + invoiceId, + ShippingLineInvoiceActionType.MarkPaid, + user.id, + dto.reason, + dto.paymentReference, + ); + } + + @Post("invoices/:invoiceId/cancel-request") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceCancel) + @ApiOperation({ + summary: + "Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).", + }) + async requestCancel( + @Param("invoiceId", ParseUUIDPipe) invoiceId: string, + @Body() dto: RequestInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.requestInvoiceAction( + invoiceId, + ShippingLineInvoiceActionType.Cancel, + user.id, + dto.reason, + ); + } + + @Post("invoice-actions/:approvalId/approve") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceApprove) + @ApiOperation({ + summary: + "Approve a pending invoice request — executes the offline settlement or the cancellation.", + }) + async approveInvoiceAction( + @Param("approvalId", ParseUUIDPipe) approvalId: string, + @Body() dto: DecideInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.decideInvoiceAction( + approvalId, + user.id, + true, + dto.note, + ); + } + + @Post("invoice-actions/:approvalId/reject") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceReject) + @ApiOperation({ + summary: "Reject a pending invoice request — nothing is changed.", + }) + async rejectInvoiceAction( + @Param("approvalId", ParseUUIDPipe) approvalId: string, + @Body() dto: DecideInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.decideInvoiceAction( + approvalId, + user.id, + false, + dto.note, + ); + } + // Declared before the parameterised staff routes so "me" is never captured // as a shipping-line id. @Get("me") diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts index fac89e0df..f1413cbaf 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts @@ -76,25 +76,32 @@ export class ShippingLineCreditsRepository extends BaseRepository { - const rows = await this.credits + const qb = this.credits .createQueryBuilder("credit") .select("credit.status", "status") .addSelect("COALESCE(SUM(credit.amount), 0)", "amount") .addSelect("COUNT(*)", "count") - .where("credit.shippingLineCompanyId = :shippingLineCompanyId", { - shippingLineCompanyId, - }) - .andWhere("credit.status IN (:...statuses)", { + .where("credit.status IN (:...statuses)", { statuses: [...OUTSTANDING_CREDIT_STATUSES], }) .andWhere("credit.deletedAt IS NULL") - .groupBy("credit.status") - .getRawMany<{ status: string; amount: string; count: string }>(); + .groupBy("credit.status"); + if (shippingLineCompanyId) { + qb.andWhere("credit.shippingLineCompanyId = :shippingLineCompanyId", { + shippingLineCompanyId, + }); + } + const rows = await qb.getRawMany<{ + status: string; + amount: string; + count: string; + }>(); const totals = (status: ShippingLineCreditStatus) => { const row = rows.find((r) => r.status === status); @@ -117,19 +124,22 @@ export class ShippingLineCreditsRepository extends BaseRepository { return this.credits.findAndCount({ where: { - shippingLineCompanyId, + ...(shippingLineCompanyId ? { shippingLineCompanyId } : {}), ...(status ? { status } : {}), }, - relations: { booking: true, invoice: true }, + relations: { booking: true, invoice: true, shippingLineCompany: true }, order: { createdAt: "DESC" }, skip, take, diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts index 0348d08bd..43e0b5c40 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts @@ -77,6 +77,14 @@ describe("ShippingLineCreditsService", () => { service = new ShippingLineCreditsService( dataSource as never, creditsRepo as never, + // Approvals repo — only the invoice maker–checker paths touch it. + { + findPendingByInvoice: jest.fn(), + findPendingByInvoiceIds: jest.fn().mockResolvedValue([]), + findByIdForUpdate: jest.fn(), + create: jest.fn(), + update: jest.fn(), + } as never, billing as never, shippingLines as never, ); diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts index 4241a796f..2fffc4c2e 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts @@ -21,7 +21,14 @@ import { ShippingLineCredit, ShippingLineCreditStatus, } from "./entities/shipping-line-credit.entity"; +import { + ShippingLineInvoiceApproval, + ShippingLineInvoiceActionStatus, + ShippingLineInvoiceActionType, +} from "./entities/shipping-line-invoice-approval.entity"; import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository"; +import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository"; +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; /** A charge to record against a shipping line's booking. */ @@ -39,6 +46,18 @@ export interface GenerateCreditInvoiceOptions { dueInDays?: number; } +/** + * Emitted by the booking-transition accept path for shipping-line bookings. + * An event rather than a service call: BookingsModule cannot import the + * shipping-line modules without closing a module cycle. + */ +export interface ShippingLineBookingAcceptedPayload { + bookingId: string; + reference: string; + /** The booking's priced total, frozen at completion time. */ + amount: number; +} + /** * The credit ledger for shipping lines — "use the service now, pay later". * @@ -66,6 +85,7 @@ export class ShippingLineCreditsService { constructor( private readonly dataSource: DataSource, private readonly credits: ShippingLineCreditsRepository, + private readonly approvals: ShippingLineInvoiceApprovalsRepository, private readonly billing: BillingService, private readonly shippingLines: ShippingLineCompaniesService, ) {} @@ -144,6 +164,32 @@ export class ShippingLineCreditsService { return manager ? run(manager) : this.dataSource.transaction(run); } + /** + * The moment a shipping-line booking becomes debt: Operations accepted it. + * Swallows its own failures with a loud log instead of throwing — the accept + * has already committed, and failing the staff response for a ledger write + * would present a succeeded accept as an error. `recordCredit` is idempotent + * per booking, so a re-accepted (previously reverted) booking cannot double + * the debt. + */ + @OnEvent("shipping_line_booking.accepted") + async onBookingAccepted( + payload: ShippingLineBookingAcceptedPayload, + ): Promise { + try { + await this.recordCredit({ + bookingId: payload.bookingId, + amount: payload.amount, + // Shipping lines are always billed in ETB (enforced at completion). + currency: "ETB", + }); + } catch (err) { + this.logger.error( + `Failed to record credit for accepted shipping-line booking ${payload.reference} (${payload.bookingId}): ${(err as Error).message} — the debt is NOT on the ledger; record it manually or re-trigger.`, + ); + } + } + // ── 2. Bill ──────────────────────────────────────────────────────────────── /** @@ -326,6 +372,39 @@ export class ShippingLineCreditsService { return this.credits.outstandingFor(shippingLineCompanyId); } + /** + * Back-office overview: outstanding totals across every line, or one line + * when an id is given. + */ + async summary(shippingLineCompanyId?: string) { + if (shippingLineCompanyId) { + await this.requireShippingLine(shippingLineCompanyId); + } + return this.credits.outstandingFor(shippingLineCompanyId); + } + + /** + * The whole ledger across every shipping line, newest first — finance's + * landing list. Optionally narrowed to one line and/or one status. + */ + async listAll( + page = 1, + pageSize = 20, + status?: ShippingLineCreditStatus, + shippingLineCompanyId?: string, + ) { + if (shippingLineCompanyId) { + await this.requireShippingLine(shippingLineCompanyId); + } + const [items, total] = await this.credits.findAllPaginated( + shippingLineCompanyId, + (page - 1) * pageSize, + pageSize, + status, + ); + return { items, total, page, pageSize }; + } + /** Full ledger for one line, newest first. */ async listCredits( shippingLineCompanyId: string, @@ -369,6 +448,287 @@ export class ShippingLineCreditsService { }; } + // ── Credit invoices: list + maker–checker manual actions ───────────────── + + /** + * Staff list of the invoices minted from credit batches, each with its line + * name and any undecided manual-action request attached — the data the + * back-office actions column renders from. + */ + async listCreditInvoices( + page = 1, + pageSize = 20, + status?: Freight.InvoiceStatus, + shippingLineCompanyId?: string, + ) { + const [invoices, total] = await this.dataSource + .getRepository(Invoice) + .findAndCount({ + where: { + source: Freight.InvoiceSource.ShippingLineCredit, + ...(status ? { status } : {}), + ...(shippingLineCompanyId ? { shippingLineCompanyId } : {}), + }, + order: { createdAt: "DESC" }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + const lineIds = [ + ...new Set( + invoices + .map((inv) => inv.shippingLineCompanyId) + .filter((id): id is string => !!id), + ), + ]; + const lines = lineIds.length + ? await this.dataSource + .getRepository(ShippingLineCompany) + .find({ where: { id: In(lineIds) } }) + : []; + const nameById = new Map(lines.map((l) => [l.id, l.name])); + + const pending = await this.approvals.findPendingByInvoiceIds( + invoices.map((inv) => inv.id), + ); + const pendingByInvoice = new Map(pending.map((p) => [p.invoiceId, p])); + + return { + items: invoices.map((inv) => ({ + ...inv, + shippingLineName: inv.shippingLineCompanyId + ? (nameById.get(inv.shippingLineCompanyId) ?? null) + : null, + pendingAction: pendingByInvoice.get(inv.id) ?? null, + })), + total, + page, + pageSize, + }; + } + + /** Undecided requests for a batch of invoices — feeds any invoice list. */ + async pendingInvoiceActions( + invoiceIds: string[], + ): Promise { + // Bounded to a list page's worth of ids; anything larger is a misuse. + return this.approvals.findPendingByInvoiceIds(invoiceIds.slice(0, 100)); + } + + /** + * Finance raises a manual action on a credit invoice: record an offline + * payment (MARK_PAID) or void it (CANCEL). Nothing happens to the invoice + * yet — a chief with the matching approve permission decides it. One + * undecided request per invoice (backed by a partial unique index). + */ + async requestInvoiceAction( + invoiceId: string, + action: ShippingLineInvoiceActionType, + requestedBy: string, + reason: string, + paymentReference?: string, + ): Promise { + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: invoiceId } }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.source !== Freight.InvoiceSource.ShippingLineCredit) { + throw new BadRequestException( + "Manual actions here apply only to shipping-line credit invoices.", + ); + } + // Fast feedback only — the billing service re-validates authoritatively + // (under lock) when the request is approved. + if ( + action === ShippingLineInvoiceActionType.MarkPaid && + invoice.status === Freight.InvoiceStatus.Paid + ) { + throw new BadRequestException("Invoice is already paid."); + } + if (invoice.status === Freight.InvoiceStatus.Cancelled) { + throw new BadRequestException("Invoice is already cancelled."); + } + if ( + action === ShippingLineInvoiceActionType.Cancel && + Number(invoice.paidAmount) > 0 + ) { + throw new BadRequestException( + "Cannot cancel an invoice that has payments recorded against it.", + ); + } + const existing = await this.approvals.findPendingByInvoice(invoiceId); + if (existing) { + throw new BadRequestException( + `A ${existing.action} request is already awaiting decision on this invoice.`, + ); + } + + const approval = await this.approvals.create({ + invoiceId, + action, + status: ShippingLineInvoiceActionStatus.Pending, + requestedBy, + reason, + paymentReference: paymentReference ?? null, + }); + + logCtx( + { + approvalId: approval.id, + invoiceId, + invoiceNumber: invoice.invoiceNumber, + action, + requestedBy, + }, + { path: "shippingLineCredit.invoiceAction.requested" }, + ); + + return approval; + } + + /** + * Decide a pending request. Gated purely by permission (the approve/reject + * grants on the controller routes) — a decider holding the grant may decide + * ANY pending request, their own included; that trade-off is deliberate. + * + * Approval executes the real action through the billing service AFTER the + * decision row commits — its settlement/cancellation events must fire from + * billing's own committed transaction (the credits listeners react to + * them). If billing then rejects the action, the decision is compensated + * back to PENDING so the request is not silently lost. + */ + async decideInvoiceAction( + approvalId: string, + decidedBy: string, + approve: boolean, + note?: string, + ): Promise { + if (!approve && !note?.trim()) { + throw new BadRequestException( + "A note is required when rejecting a request.", + ); + } + + const decided = await this.dataSource.transaction(async (mg) => { + const approval = await this.approvals.findByIdForUpdate(mg, approvalId); + if (!approval) { + throw new NotFoundException(`Request ${approvalId} not found`); + } + if (approval.status !== ShippingLineInvoiceActionStatus.Pending) { + throw new BadRequestException( + `This request was already ${approval.status.toLowerCase()}.`, + ); + } + + const status = approve + ? ShippingLineInvoiceActionStatus.Approved + : ShippingLineInvoiceActionStatus.Rejected; + await mg.update( + ShippingLineInvoiceApproval, + { id: approvalId }, + { + status, + decidedBy, + decidedAt: new Date(), + decisionNote: note ?? null, + }, + ); + return { ...approval, status, decidedBy, decisionNote: note ?? null }; + }); + + if (!approve) { + logCtx( + { approvalId, invoiceId: decided.invoiceId, decidedBy }, + { path: "shippingLineCredit.invoiceAction.rejected" }, + ); + return decided; + } + + try { + if (decided.action === ShippingLineInvoiceActionType.MarkPaid) { + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: decided.invoiceId } }); + if (!invoice) { + throw new NotFoundException(`Invoice ${decided.invoiceId} not found`); + } + // Full settlement of the outstanding balance; billing emits + // `shipping_line_credit.invoice.paid`, which marks the credits PAID. + await this.billing.recordPayment(decided.invoiceId, { + amount: Number(invoice.balanceAmount ?? invoice.totalAmount), + method: "OFFLINE", + reference: decided.paymentReference ?? undefined, + metadata: { + approvalId: decided.id, + requestedBy: decided.requestedBy, + approvedBy: decidedBy, + }, + }); + } else { + // Billing emits `shipping_line_credit.invoice.cancelled`; + // onInvoiceCancelled releases the credits back to the unbilled pool. + await this.billing.cancelInvoice(decided.invoiceId); + } + } catch (err) { + // The action was refused (state changed since the request — e.g. the + // line paid through CBE in the meantime). Put the request back so it is + // not recorded as approved-but-unexecuted. + await this.approvals.update(approvalId, { + status: ShippingLineInvoiceActionStatus.Pending, + decidedBy: null, + decidedAt: null, + decisionNote: null, + }); + throw err; + } + + logCtx( + { + approvalId, + invoiceId: decided.invoiceId, + action: decided.action, + decidedBy, + }, + { path: "shippingLineCredit.invoiceAction.approved" }, + ); + + return decided; + } + + /** + * When a credit invoice is cancelled — through the approval flow or any + * other billing path — its BILLED credits return to the unbilled pool so + * the debt can be re-billed. The debt itself never disappears on invoice + * cancellation; only {@link cancelCredit} writes debt off. + */ + @OnEvent("shipping_line_credit.invoice.cancelled") + async onInvoiceCancelled(payload: InvoiceEventPayload): Promise { + const result = await this.dataSource + .getRepository(ShippingLineCredit) + .update( + { + invoiceId: payload.invoiceId, + status: ShippingLineCreditStatus.Billed, + }, + { + status: ShippingLineCreditStatus.Unbilled, + invoiceId: null, + billedAt: null, + }, + ); + + logCtx( + { + invoiceId: payload.invoiceId, + invoiceNumber: payload.invoiceNumber, + creditsReleased: result.affected ?? 0, + }, + { path: "shippingLineCredit.invoiceCancelled.released" }, + ); + } + // ── Cancellation ─────────────────────────────────────────────────────────── /** diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-invoice-approvals.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-invoice-approvals.repository.ts new file mode 100644 index 000000000..dcd904fa2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-invoice-approvals.repository.ts @@ -0,0 +1,51 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, In, Repository } from "typeorm"; + +import { + ShippingLineInvoiceApproval, + ShippingLineInvoiceActionStatus, +} from "./entities/shipping-line-invoice-approval.entity"; + +@Injectable() +export class ShippingLineInvoiceApprovalsRepository extends BaseRepository { + constructor( + @InjectRepository(ShippingLineInvoiceApproval) + private readonly approvals: Repository, + ) { + super(approvals); + } + + findPendingByInvoice( + invoiceId: string, + ): Promise { + return this.approvals.findOne({ + where: { invoiceId, status: ShippingLineInvoiceActionStatus.Pending }, + }); + } + + /** Pending requests for a page of invoices — one query, no N+1. */ + findPendingByInvoiceIds( + invoiceIds: string[], + ): Promise { + if (!invoiceIds.length) return Promise.resolve([]); + return this.approvals.find({ + where: { + invoiceId: In(invoiceIds), + status: ShippingLineInvoiceActionStatus.Pending, + }, + }); + } + + /** Load one request inside the caller's transaction, locked for decision. */ + findByIdForUpdate( + manager: EntityManager, + id: string, + ): Promise { + return manager.getRepository(ShippingLineInvoiceApproval).findOne({ + where: { id }, + lock: { mode: "pessimistic_write" }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index b9c115d53..4418eed3d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -26,6 +26,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { formatRouteLabel } from '../routes/entities/route.entity'; +import { isRoadService } from '../bookings/road.util'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { CargoType } from '../rule-engine/entities/cargo-type.entity'; @@ -154,6 +155,19 @@ export interface ExportTrainOption { }>; } +/** Form-entered cargo for a train-options probe (nothing persisted yet). */ +export interface TrainOptionCargoOverrides { + /** Container types drive the per-type space. */ + containerTypeIds?: string[]; + /** Size labels ("20ft"/"40ft") when the form has no type ids. */ + containerSizes?: string[]; + /** Bulk counterparts of the container inputs. */ + cargoTypeId?: string; + cargoTypeCode?: string; + /** Needed wagons estimate from the form (drives the `fits` flag). */ + wagons?: number; +} + /** A train a paid-unallocated booking can board (route + capacity verified). */ export interface AllocationCandidate { id: string; @@ -1051,19 +1065,84 @@ export class BookingBatchService implements OnModuleInit { async exportTrainOptionsForDay( booking: Booking, day: string, - overrides?: { - /** Cargo the customer is entering on a form (bare contract instance — - * nothing persisted yet): container types drive the per-type space. */ - containerTypeIds?: string[]; - /** Size labels ("20ft"/"40ft") when the form has no type ids. */ - containerSizes?: string[]; - /** Bulk counterparts of the container inputs. */ - cargoTypeId?: string; - cargoTypeCode?: string; - /** Needed wagons estimate from the form (drives the `fits` flag). */ - wagons?: number; - }, + overrides?: TrainOptionCargoOverrides, ): Promise { + booking = await this.withCargoOverrides(booking, overrides); + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.direction === 'EXPORT', + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + return this.buildTrainOptions(booking, candidates); + } + + /** + * The same per-train wagon-availability cards, but for the trains DEDICATED + * to a shipping line on the booking's lane + day. Same option shape as the + * export picker so the portal reuses the same component; `isOpen` + * additionally respects the dedicated close offset (windowClosesAt), since + * these trains run no window cycle. + */ + async dedicatedTrainOptionsForDay( + booking: Booking, + day: string | null, + shippingLineCompanyId: string, + overrides?: TrainOptionCargoOverrides, + ): Promise { + booking = await this.withCargoOverrides(booking, overrides); + const dedicated = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId }, + ], + }); + const candidates = dedicated + .filter( + (s) => + s.scheduledDepartureDate != null && + // A day narrows to that departure day; without one, every upcoming + // departure on the lane is listed (the picker's full card list). + (day + ? eatDay(s.scheduledDepartureDate) === day + : s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) && + (!booking.originYardId || s.originStationId === booking.originYardId) && + (!booking.destinationYardId || + s.destinationStationId === booking.destinationYardId), + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + const options = await this.buildTrainOptions(booking, candidates); + const now = Date.now(); + return options.map((o) => ({ + ...o, + isOpen: + o.isOpen && + (o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now), + })); + } + + /** Resolve form-entered cargo onto an (unpersisted) booking probe. */ + private async withCargoOverrides( + booking: Booking, + overrides?: TrainOptionCargoOverrides, + ): Promise { const sizeFts = (overrides?.containerSizes ?? []) .map((s) => parseInt(s, 10)) .filter((n) => Number.isFinite(n) && n > 0); @@ -1095,26 +1174,14 @@ export class BookingBatchService implements OnModuleInit { if (overrides?.wagons && overrides.wagons > 0) { booking = { ...booking, wagonsRequired: overrides.wagons } as Booking; } - const corridor = await this.trainSchedulesRepository.findAll({ - where: [ - // Dedicated shipping-line trains are never customer-booking targets. - { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, - { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, - ], - }); - const candidates = corridor - .filter( - (s) => - s.scheduledDepartureDate != null && - eatDay(s.scheduledDepartureDate) === day && - s.direction === 'EXPORT', - ) - .sort( - (a, b) => - a.scheduledDepartureDate!.getTime() - - b.scheduledDepartureDate!.getTime(), - ); + return booking; + } + /** One availability card per candidate schedule — the export picker's math. */ + private async buildTrainOptions( + booking: Booking, + candidates: TrainSchedule[], + ): Promise { const wagonDims = await this.loadWagonDims(); const allowed = this.allowedDimsWithTypes(booking, wagonDims); const neededWagons = this.wagonsFor(booking, wagonDims); @@ -3185,6 +3252,99 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * Auto-allocate an accepted SHIPPING-LINE booking onto its company's + * dedicated train for the booking's lane and shipment day. + * + * Runs at operation-accept: shipping lines pay later on the credit ledger, + * so there is no pay window between accept and wagon placement — the + * booking boards its train immediately. Customer bookings never come here; + * they keep the batch pool → reserve → pay → allocate pipeline. + * + * Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule + * (without the PAID stamps the customer hold writes — nothing was paid). + * No dedicated train on the day is not an error: the booking simply stays + * in the ordinary day pool for the batch engine. + */ + async allocateShippingLineAccepted(bookingId: string): Promise { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + relations: { bookingContainers: { containerType: true }, cargoType: true }, + }); + if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return; + if (isRoadService(booking.serviceType)) return; + + const day = eatDay(booking.scheduledDate); + const dedicated = await this.dataSource.getRepository(TrainSchedule).find({ + where: [ + { + shippingLineCompanyId: booking.shippingLineCompanyId, + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + shippingLineCompanyId: booking.shippingLineCompanyId, + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const target = dedicated.find( + (s) => + s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day, + ); + if (!target) { + this.logger.log( + `[BATCH] shipping-line booking ${booking.reference} has no dedicated ` + + `train on ${day} — left in the day pool for the batch engine`, + ); + return; + } + + // Point the booking at its train BEFORE the shortage probe — the probe + // reads the link to size the need against that schedule's wagons. + await this.dataSource.getRepository(Booking).update(booking.id, { + trainScheduleId: target.id, + } as never); + booking.trainScheduleId = target.id; + + // One dedicated train carries ONE booking: the accept claims the train by + // closing its booking window on the spot. Both gates a later booking + // passes — the day picker (isStillOpen on windowClosesAt) and the + // completion's dedicated-day check — read these fields, so a second + // booking can never pick this train. + await this.dataSource.getRepository(TrainSchedule).update(target.id, { + bookingWindowStatus: "CLOSED", + windowClosesAt: new Date(), + } as never); + this.notifyBoardChanged(target.id, "shipping_line_train_claimed"); + + const shortage = + await this.trainSchedulingService.previewPaidBookingWagonShortage( + target.id, + booking.id, + ); + if (shortage) { + // Parked for staff to attach wagons — WITHOUT the customer hold's PAID + // stamps: a shipping line has paid nothing, its debt sits on the ledger. + await this.dataSource.getRepository(Booking).update(booking.id, { + schedulingStatus: "WAITING_FOR_WAGON", + } as never); + this.logger.warn( + `Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` + + `dedicated train ${target.reference ?? target.id}: needs ` + + `${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` + + `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`, + ); + this.notifyBoardChanged(target.id, "booking_waiting_wagon"); + return; + } + + await this.allocate(target.id, booking, "shipping_line"); + } + /** * One reminder per hold, shortly before its pay deadline (the window tick * calls this every pass; `payment_reminder_sent_at` dedups). Skips paid @@ -3544,7 +3704,7 @@ export class BookingBatchService implements OnModuleInit { private async allocate( scheduleId: string, booking: Booking, - reason: "paid" | "gov", + reason: "paid" | "gov" | "shipping_line", ): Promise { // Stamp the computed wagon need on the link. Several callers pass a booking // loaded without cargo relations (ensurePaidBookingAllocated), and a NULL diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 9468387c5..1c2d99401 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -12,6 +12,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; +import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @@ -66,10 +67,16 @@ export class BookingNotifierService { ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); // One resolver for both channels — the company row's own email column is - // only set for a Fayda-verified owner (see companyNotifyEmailExpr). - const { phone, email } = b.companyId - ? await resolveCompanyNotifyContact(this.dataSource, b.companyId) - : { phone: null, email: null }; + // only set for a Fayda-verified owner (see companyNotifyEmailExpr). A + // shipping-line booking has no company; its contact is the line's row. + const { phone, email } = b.shippingLineCompanyId + ? await resolveShippingLineNotifyTarget( + this.dataSource, + b.shippingLineCompanyId, + ) + : b.companyId + ? await resolveCompanyNotifyContact(this.dataSource, b.companyId) + : { phone: null, email: null }; if (phone) { try { @@ -90,13 +97,42 @@ export class BookingNotifierService { } } - /** Persist + push an in-app item to all portal users of the booking's company. */ + /** + * Persist + push an in-app item to the booking's portal owner: every portal + * user of the company, or — for a shipping-line booking — the line's own + * account, deep-linked into the shipping-line app (/shipping-line/*). + */ private inApp( b: Booking, title: string, body: string, overrides: Partial = {}, ): void { + if (b.shippingLineCompanyId) { + void (async () => { + const { userId } = await resolveShippingLineNotifyTarget( + this.dataSource, + b.shippingLineCompanyId!, + ); + if (!userId) return; + void this.inbox.notify({ + recipients: { userIds: [userId] }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title, + body, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + // After the spread: the bell must land the line on ITS booking page. + link: `/shipping-line/bookings/${b.id}`, + }); + })().catch((err) => + this.logger.warn( + `shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`, + ), + ); + return; + } if (!b.companyId) return; // government/unlinked bookings have no portal users void this.inbox.notify({ recipients: { companyId: b.companyId }, @@ -209,11 +245,19 @@ export class BookingNotifierService { }); } - secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void { + secured( + b: Booking, + reason: 'paid' | 'gov' | 'shipping_line', + scheduleId?: string | null, + ): void { void (async () => { const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId); const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${ - reason === 'gov' ? ' (government)' : '' + reason === 'gov' + ? ' (government)' + : reason === 'shipping_line' + ? ' (shipping line)' + : '' }.`; void this.notifyContact(b, msg, 'ALLOCATED'); this.inApp(b, 'Wagon allocated', msg); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 7125e3bbf..12fb3dec6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -4531,7 +4531,10 @@ export class TrainSchedulingService { (b) => !(targetScheduleId && b.trainScheduleId === targetScheduleId) && !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') && - !b.isGovernment, + !b.isGovernment && + // Shipping-line bookings pay later on the credit ledger — never PAID + // up front, schedulable from accept (FULLY_EXECUTED) like government. + !b.shippingLineCompanyId, ); if (invalidStatus.length) { const statuses = [...new Set(invalidStatus.map((b) => b.status))]; @@ -8647,7 +8650,12 @@ export class TrainSchedulingService { .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); const eligible = linkedBookings.filter( - (b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment, + (b) => + SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || + b.isGovernment || + // Shipping-line bookings board without paying up front — their charge + // sits on the credit ledger, so accept (FULLY_EXECUTED) is boardable. + Boolean(b.shippingLineCompanyId), ); if (!eligible.length) return empty; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 81b3336de..adbd22584 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -595,6 +595,28 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:shipping_line_credits:cancel", "Cancel (write off) an unbilled shipping-line credit", ), + // Two-step manual actions on credit invoices: request grants per action, + // decision grants that apply to any pending request. + perm( + "d2c00001-0001-4000-8000-000000000004", + "edr_freight_app:shipping_line_credits:invoice_mark_paid", + "Request marking a shipping-line credit invoice paid (offline payment)", + ), + perm( + "d2c00001-0001-4000-8000-000000000005", + "edr_freight_app:shipping_line_credits:invoice_approve", + "Approve any pending shipping-line credit invoice request", + ), + perm( + "d2c00001-0001-4000-8000-000000000006", + "edr_freight_app:shipping_line_credits:invoice_cancel", + "Request cancelling a shipping-line credit invoice", + ), + perm( + "d2c00001-0001-4000-8000-000000000007", + "edr_freight_app:shipping_line_credits:invoice_reject", + "Reject any pending shipping-line credit invoice request", + ), ]; // E. First / last mile operations @@ -1829,6 +1851,18 @@ export const FREIGHT_PERMS = { invoice: "edr_freight_app:shipping_line_credits:invoice", /** Write off an unbilled credit — separate grant: it erases a debt. */ cancel: "edr_freight_app:shipping_line_credits:cancel", + // Two-step manual actions on credit invoices, gated purely by permission: + // finance-level REQUEST grants (per action) and decision grants that apply + // to ANY pending request — including the holder's own. + /** Request recording an offline payment against a credit invoice. */ + invoiceMarkPaid: + "edr_freight_app:shipping_line_credits:invoice_mark_paid", + /** Request voiding a credit invoice (credits return to unbilled). */ + invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel", + /** Approve any pending invoice request (mark-paid or cancel). */ + invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve", + /** Reject any pending invoice request. */ + invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", }, payments: { view: "edr_freight_app:payments:view", @@ -2377,6 +2411,13 @@ export const ROLE_PERMISSION_PRESETS = { // exceptional operations, and are assigned to named admins rather than a role preset. FREIGHT_PERMS.payments.view, FREIGHT_PERMS.bookings.wagonCancellationView, + // Shipping-line credit ledger is a Finance surface: bill batches into + // invoices and RAISE manual invoice actions. Approval of those actions is + // deliberately absent — it sits with the chief (maker–checker). + FREIGHT_PERMS.shippingLineCredits.view, + FREIGHT_PERMS.shippingLineCredits.invoice, + FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid, + FREIGHT_PERMS.shippingLineCredits.invoiceCancel, ], // Global Logistics: manages ONLY the customs-clearance queue. Scoped out of // the general booking-request list (no bookings:view) — instead a dedicated @@ -2479,6 +2520,11 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.payments.view, + // Decision side of the credit-invoice two-step: finance raises + // mark-paid/cancel requests, the chief approves or rejects them. + FREIGHT_PERMS.shippingLineCredits.view, + FREIGHT_PERMS.shippingLineCredits.invoiceApprove, + FREIGHT_PERMS.shippingLineCredits.invoiceReject, ]), // Director additionally manages train scheduling + rail fleet (same block the // operation officer/chief hold), on top of the approval-chain role preset, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index cb4699aa1..6eda68ae1 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -37,6 +37,7 @@ import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPag import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage"; +import ShippingLineCreditsPage from "./pages/shipping-lines/ShippingLineCreditsPage"; import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; import FinanceHubPage from "./pages/invoices/FinanceHubPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; @@ -309,6 +310,16 @@ const App = () => { } /> + + + + } + /> Number(c.hazardousQuantity ?? 0) > 0); + const isReefer = + booking.isReefer || + containers.some((c) => Number(c.reeferQuantity ?? 0) > 0); + const showHandlingColumns = containers.some( + (c) => + Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0, + ); + return ( @@ -27,11 +40,33 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { {items != null && } + + {/* Handling that changes how the yard treats the shipment is flagged + loudly, not buried in the grid. */} + {(isHazardous || isReefer) && ( + + {isHazardous && ( + + Hazardous cargo + + )} + {isReefer && ( + + Refrigerated cargo + + )} + + )} + {containers.length > 0 && ( <> @@ -42,6 +77,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { Container type Qty VGM / unit + {showHandlingColumns && Hazardous} + {showHandlingColumns && Reefer} @@ -54,6 +91,28 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { {c.quantity} {c.vgmPerUnitTons} t + {showHandlingColumns && ( + + {Number(c.hazardousQuantity ?? 0) > 0 ? ( + + {c.hazardousQuantity} + + ) : ( + "—" + )} + + )} + {showHandlingColumns && ( + + {Number(c.reeferQuantity ?? 0) > 0 ? ( + + {c.reeferQuantity} + + ) : ( + "—" + )} + + )} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 813e11d1c..293e82450 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -24,6 +24,7 @@ import { Send, Settings, ShieldCheck, + HandCoins, Ship, SlidersHorizontal, Train, @@ -76,6 +77,12 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.shippingLines.view, }, + { + label: "Shipping Line Credits", + href: "/dashboard/shipping-line-credits", + icon: , + permission: FREIGHT_PERMS.shippingLineCredits.view, + }, { label: "Contracts", href: "/dashboard/contract-requests", diff --git a/apps/edr-freight-web/backoffice/src/components/shipping-lines/CreditInvoiceActions.tsx b/apps/edr-freight-web/backoffice/src/components/shipping-lines/CreditInvoiceActions.tsx new file mode 100644 index 000000000..6088d58f1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/shipping-lines/CreditInvoiceActions.tsx @@ -0,0 +1,363 @@ +import { + Badge, + Button, + Group, + Modal, + Stack, + Text, + Textarea, + TextInput, +} from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { Ban, Check, HandCoins, X } from "lucide-react"; +import { useState } from "react"; + +import { useAuth } from "@/auth/useAuth"; +import { formatMoney } from "@/components/customers"; +import { useToast } from "@/hooks/use-toast"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { api } from "@/services/api"; +import type { + CreditInvoiceActionType, + CreditInvoicePendingAction, +} from "@/types/shippingLineCredit"; + +/** The slice of an invoice row the actions need — both list pages have it. */ +export interface CreditInvoiceActionTarget { + id: string; + invoiceNumber: string; + status: string; + currency: string; + totalAmount: string | number; + paidAmount: string | number; + balanceAmount: string | number; +} + +/** Statuses an offline payment can still be recorded against. */ +const MARK_PAID_STATUSES = new Set([ + "ISSUED", + "PENDING", + "PAYMENT_PROCESSING", + "PARTIALLY_PAID", + "OVERDUE", +]); + +const ACTION_LABEL: Record = { + MARK_PAID: "Mark paid", + CANCEL: "Cancel invoice", +}; + +export interface CreditInvoiceActionsProps { + invoice: CreditInvoiceActionTarget; + pendingAction: CreditInvoicePendingAction | null; +} + +/** + * Two-step actions for ONE shipping-line credit invoice, embeddable in any + * invoice list. Gated purely by permission: the request grants raise + * mark-paid / cancel, the approve/reject grants decide ANY pending request — + * the holder's own included. Renders only the buttons the signed-in user's + * grants allow; the API enforces the same gates server-side. + */ +export default function CreditInvoiceActions({ + invoice, + pendingAction, +}: CreditInvoiceActionsProps) { + const { user } = useAuth(); + const { toast } = useToast(); + + const canRequestPaid = hasPermission( + user, + FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid, + ); + const canRequestCancel = hasPermission( + user, + FREIGHT_PERMS.shippingLineCredits.invoiceCancel, + ); + const canApprove = hasPermission( + user, + FREIGHT_PERMS.shippingLineCredits.invoiceApprove, + ); + const canReject = hasPermission( + user, + FREIGHT_PERMS.shippingLineCredits.invoiceReject, + ); + + const [requestAction, setRequestActionModal] = + useState(null); + const [reason, setReason] = useState(""); + const [paymentReference, setPaymentReference] = useState(""); + const [decideApprove, setDecideApprove] = useState(null); + const [decisionNote, setDecisionNote] = useState(""); + + const closeRequest = () => { + setRequestActionModal(null); + setReason(""); + setPaymentReference(""); + }; + const closeDecide = () => { + setDecideApprove(null); + setDecisionNote(""); + }; + + const { mutate: submitRequest, isPending: isRequesting } = useMutation( + api.shippingLineCredits.requestInvoiceAction.mutationOptions({ + onSuccess: (_, variables) => { + closeRequest(); + toast({ + title: "Request submitted", + description: `${ACTION_LABEL[variables.action]} on ${invoice.invoiceNumber} now awaits a chief's approval.`, + }); + }, + onError: (err) => + toast({ + title: "Could not submit request", + description: err.message, + variant: "destructive", + }), + }), + ); + + const { mutate: submitDecision, isPending: isDeciding } = useMutation( + api.shippingLineCredits.decideInvoiceAction.mutationOptions({ + onSuccess: (_, variables) => { + closeDecide(); + toast({ + title: variables.approve ? "Request approved" : "Request rejected", + description: variables.approve + ? pendingAction?.action === "MARK_PAID" + ? "The offline payment was recorded; the invoice and its credits are now paid." + : "The invoice was cancelled; its credits returned to the unbilled pool." + : "The request was rejected and nothing was changed.", + }); + }, + onError: (err) => + toast({ + title: "Could not decide request", + description: err.message, + variant: "destructive", + }), + }), + ); + + let body = null; + if (pendingAction) { + body = ( + + + {ACTION_LABEL[pendingAction.action]} — awaiting approval + + {canApprove || canReject ? ( + + {canApprove ? ( + + ) : null} + {canReject ? ( + + ) : null} + + ) : null} + + ); + } else { + const showMarkPaid = + canRequestPaid && MARK_PAID_STATUSES.has(invoice.status); + const showCancel = + canRequestCancel && + invoice.status !== "CANCELLED" && + invoice.status !== "PAID" && + invoice.status !== "REFUNDED" && + Number(invoice.paidAmount) === 0; + body = + !showMarkPaid && !showCancel ? ( + + — + + ) : ( + + {showMarkPaid ? ( + + ) : null} + {showCancel ? ( + + ) : null} + + ); + } + + return ( + <> + {body} + + {/* Maker: raise the request. */} + + + + {requestAction === "MARK_PAID" + ? "Records a full offline settlement of the outstanding balance. Takes effect only after a chief approves." + : "Voids the invoice and returns its credits to the unbilled pool. Takes effect only after a chief approves."} + + {requestAction === "MARK_PAID" ? ( + setPaymentReference(e.currentTarget.value)} + /> + ) : null} +