diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index b0da6ac24..810c1a55a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -92,7 +92,7 @@ export class BillingController { @Get("offline-usd") @ApiOperation({ summary: - "Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context", + "Finance worklist: USD and ETB invoices settled manually (bank transfer / counter), with booking pay-window context", }) findOfflineUsd(@Query() query: FilterInvoiceDto) { return this.billingService.findOfflineUsdPaginated(query); @@ -104,7 +104,7 @@ export class BillingController { @ApiConsumes("multipart/form-data") @ApiOperation({ summary: - "Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", + "Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance", }) confirmOffline( @Param("id", ParseUUIDPipe) id: string, 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 0825ab6dc..44e194a8d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -16,6 +16,7 @@ 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 { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { EimsInvoiceStatus } from "../eims/eims-registration.types"; @@ -48,10 +49,18 @@ export interface PayInvoiceOptions { export interface OfflineUsdBookingInfo { id: string; reference: string; + tradeDirection: string | null; paymentDeadline: Date | null; paymentStatus: string; } +/** Row shape of the manual-payments worklist. */ +export type OfflineUsdInvoiceRow = Invoice & { + booking: OfflineUsdBookingInfo | null; + /** Shipping-line credit invoices span many bookings — one entry per credit. */ + bookings: { id: string; reference: string; tradeDirection: string | null }[]; +}; + /** A single manual/offline settlement to record against an invoice. */ export interface RecordPaymentInput { /** Amount settled by this payment; must be > 0. */ @@ -340,22 +349,23 @@ export class BillingService { } /** - * Finance's offline-settlement worklist: USD invoices (paid by bank transfer, - * never through the gateway), open ones by default or a single status when - * filtered. Booking-sourced rows carry the booking's reference and pay-window - * deadline so the UI can show the countdown and link to the booking. + * Finance's manual-settlement worklist: USD invoices (paid by bank transfer, + * never through the gateway) and ETB invoices Finance settles by hand (bank + * transfer / counter) instead of the customer paying online. Open ones by + * default or a single status when filtered; both currencies unless + * `currency` narrows it. Booking-sourced rows carry the booking's reference, + * trade direction and pay-window deadline so the UI can show the countdown + * and link to the booking. */ async findOfflineUsdPaginated( filter: { status?: Freight.InvoiceStatus; search?: string; + currency?: "USD" | "ETB"; page?: number; pageSize?: number; } = {}, - ): Promise<{ - items: (Invoice & { booking: OfflineUsdBookingInfo | null })[]; - total: number; - }> { + ): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; @@ -364,11 +374,16 @@ export class BillingService { .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") - .where("UPPER(invoice.currency) = 'USD'") + .where("UPPER(invoice.currency) IN ('USD', 'ETB')") .orderBy("invoice.issuedAt", "DESC") .skip((page - 1) * pageSize) .take(pageSize); + if (filter.currency) { + qb.andWhere("UPPER(invoice.currency) = :currency", { + currency: filter.currency, + }); + } if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } else { @@ -381,7 +396,8 @@ export class BillingService { ); } - const [items, total] = await qb.getManyAndCount(); + const [rawItems, total] = await qb.getManyAndCount(); + const items = await this.attachShippingLineCompanies(rawItems); const bookingIds = items .filter((i) => i.source === "booking") @@ -389,11 +405,43 @@ export class BillingService { const bookings = bookingIds.length ? await this.dataSource.getRepository(Booking).find({ where: { id: In(bookingIds) }, - select: ["id", "reference", "paymentDeadline", "paymentStatus"], + select: [ + "id", + "reference", + "tradeDirection", + "paymentDeadline", + "paymentStatus", + ], }) : []; const byId = new Map(bookings.map((b) => [b.id, b])); + // Shipping-line credit invoices bill many bookings at once; each credit + // keeps its own booking link, so collect them per invoice. + const creditInvoiceIds = items + .filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit) + .map((i) => i.id); + const credits = creditInvoiceIds.length + ? await this.dataSource.getRepository(ShippingLineCredit).find({ + where: { invoiceId: In(creditInvoiceIds) }, + relations: { booking: true }, + }) + : []; + const bookingsByInvoice = new Map< + string, + OfflineUsdInvoiceRow["bookings"] + >(); + for (const c of credits) { + if (!c.invoiceId || !c.booking) continue; + const list = bookingsByInvoice.get(c.invoiceId) ?? []; + list.push({ + id: c.booking.id, + reference: c.booking.reference, + tradeDirection: c.booking.tradeDirection ?? null, + }); + bookingsByInvoice.set(c.invoiceId, list); + } + return { items: items.map((inv) => { const b = byId.get(inv.sourceId); @@ -403,19 +451,22 @@ export class BillingService { ? { id: b.id, reference: b.reference, + tradeDirection: b.tradeDirection ?? null, paymentDeadline: b.paymentDeadline ?? null, paymentStatus: b.paymentStatus, } : null, - } as Invoice & { booking: OfflineUsdBookingInfo | null }; + bookings: bookingsByInvoice.get(inv.id) ?? [], + } as OfflineUsdInvoiceRow; }), total, }; } /** - * Finance confirms a USD invoice as paid by bank transfer: stores the slip - * against the invoice and settles the FULL outstanding balance through + * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer + * or counter payment: stores the slip against the invoice and settles the + * FULL outstanding balance through * {@link recordPayment}, which flips the invoice to PAID and (for bookings) * emits `booking.invoice.paid` — the same event an online payment fires, so * the booking advances exactly as if it had been paid through the gateway. @@ -434,11 +485,6 @@ export class BillingService { ): Promise { const invoice = await this.invoices.findById(invoiceId); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); - if (invoice.currency?.toUpperCase() !== "USD") { - throw new BadRequestException( - "Offline confirmation is only for USD invoices — this invoice is paid online.", - ); - } if (!file) { throw new BadRequestException("The bank payment slip file is required."); } diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index e8942d586..91327946c 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -39,4 +39,11 @@ export class FilterInvoiceDto { @IsOptional() @IsIn(Object.values(Freight.InvoiceStatus)) status?: Freight.InvoiceStatus; + + /** Manual-payments worklist only: restrict to one currency. */ + @ApiPropertyOptional({ enum: ["USD", "ETB"] }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) + @IsIn(["USD", "ETB"]) + currency?: "USD" | "ETB"; } 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 9e905df6d..24be04f66 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2414,6 +2414,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, + // Manual settlement (bank transfer / counter) of USD and ETB invoices. + FREIGHT_PERMS.invoices.confirmOffline, // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, // eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all // (the cron sweep runs as the system); these are the *manual* exceptional-operations diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 9ac730bda..c131af4bd 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -315,7 +315,7 @@ const App = () => { } /> {/* Merged Invoices / Payments / USD Payments hub — tabs switch via - ?tab=invoices|payments|usd-payments (default invoices). Access is + ?tab=invoices|payments|manual-payments (default invoices). Access is OR'd across both keys so a user with just one still gets in; each tab hides itself if the user lacks the permission it used to be routed on. */} @@ -352,7 +352,7 @@ const App = () => { /> } + element={} /> Date.now()); - useEffect(() => { if (!deadline) return; const interval = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(interval); }, [deadline]); + return now; +} + +function PayWindowCell({ deadline }: { deadline: string | null }) { + const now = useNow(deadline); if (!deadline) { return ( @@ -88,13 +94,56 @@ function PayWindowCell({ deadline }: { deadline: string | null }) { ); } -/** True once the pay window has closed — the API refuses confirmation then. */ -function windowClosed(row: OfflineUsdInvoice): boolean { - const deadline = row.booking?.paymentDeadline; - return Boolean(deadline && new Date(deadline).getTime() <= Date.now()); +/** + * "Confirm paid" for one row. Booking invoices are only confirmable while the + * booking's pay window is open (the API refuses otherwise): no window yet → + * no button; window closed → button disabled with the reason, and it flips + * live the second the countdown hits zero. Non-booking invoices (warehouse, + * clearance…) have no window and stay confirmable. + */ +function ConfirmCell({ + row, + onConfirm, +}: { + row: OfflineUsdInvoice; + onConfirm: (row: OfflineUsdInvoice) => void; +}) { + const deadline = row.booking?.paymentDeadline ?? null; + const now = useNow(deadline); + + if (row.booking && !deadline) return null; + const closed = Boolean(deadline && new Date(deadline).getTime() <= now); + + return ( + + + + + + ); } -/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */ +/** + * Manual Payments tab body of `FinanceHubPage` — page chrome lives in the + * parent. Lists open USD and ETB invoices (import and export alike) that + * Finance settles by hand; confirming records the payment the same way an + * online payment would, so the booking advances identically. + */ export default function UsdPaymentsPanel() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); @@ -103,6 +152,7 @@ export default function UsdPaymentsPanel() { const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>( "", ); + const [currency, setCurrency] = useState<"" | "USD" | "ETB">(""); const [confirming, setConfirming] = useState(null); const [slip, setSlip] = useState(null); const [reference, setReference] = useState(""); @@ -119,8 +169,15 @@ export default function UsdPaymentsPanel() { pageSize: pagination.pageSize, search: debouncedQuery, status: statusFilter || undefined, + currency: currency || undefined, }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], + [ + pagination.pageIndex, + pagination.pageSize, + debouncedQuery, + statusFilter, + currency, + ], ); const { data, isLoading, isError, refetch, isFetching } = useQuery( @@ -170,7 +227,9 @@ export default function UsdPaymentsPanel() { header: "Customer", cell: ({ row }) => ( - {row.original.company?.name ?? "—"} + {row.original.company?.name ?? + row.original.shippingLineCompany?.name ?? + "—"} ), }, @@ -179,6 +238,28 @@ export default function UsdPaymentsPanel() { header: "Booking", cell: ({ row }) => { const booking = row.original.booking; + const bookings = row.original.bookings ?? []; + if (!booking && bookings.length) { + // Shipping-line credit invoice: one link per billed booking. + return ( + + {bookings.map((b) => ( + + ))} + + ); + } if (!booking) { return ( @@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() { ); } return ( - + + + {booking.tradeDirection && ( + + {humanize(booking.tradeDirection)} + + )} + ); }, }, + { + id: "currency", + header: "Currency", + cell: ({ row }) => ( + + {row.original.currency} + + ), + }, { id: "status", header: "Status", @@ -239,22 +341,8 @@ export default function UsdPaymentsPanel() { header: "", meta: { headerClassName: "text-right", cellClassName: "text-right" }, cell: ({ row }) => { - const paid = row.original.status === "PAID"; - if (paid || !canConfirm) return null; - return ( - - ); + if (row.original.status === "PAID" || !canConfirm) return null; + return ; }, }, ], @@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() { style={{ flex: 1, minWidth: "240px" }} radius="lg" /> + { + setCurrency(v === "all" ? "" : (v as "USD" | "ETB")); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + data={[ + { label: "All", value: "all" }, + { label: "ETB", value: "ETB" }, + { label: "USD", value: "USD" }, + ]} + /> - + navigate(`/dashboard/invoices/${row.id}`)} emptyMessage={ debouncedQuery - ? "No USD invoices match your search." - : "No USD invoices awaiting confirmation." + ? "No invoices match your search." + : "No invoices awaiting manual payment confirmation." } error={ isError ? { - message: "Failed to load USD invoices.", + message: "Failed to load invoices.", onRetry: () => void refetch(), } : undefined @@ -361,7 +463,7 @@ export default function UsdPaymentsPanel() { opened={confirming !== null} onClose={closeConfirm} title={ - Confirm bank transfer payment + Confirm manual payment } radius="md" size="md" @@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() { Confirming settles {confirming.invoiceNumber} in full ( {formatMoney(confirming.balanceAmount, confirming.currency)}) and - marks the booking as paid. Upload the customer's bank slip - first — this cannot be undone. + marks the booking as paid — exactly as if the customer had paid + online. Upload the customer's bank slip or receipt first — + this cannot be undone. setReference(e.target.value)} diff --git a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts index cb4417bf0..8c39420ea 100644 --- a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts @@ -59,7 +59,7 @@ export const invoicesService = { .then((r) => r.data); }, - /** Finance worklist: USD invoices awaiting bank-transfer confirmation. */ + /** Finance worklist: USD and ETB invoices awaiting manual payment confirmation. */ listOfflineUsd( filter: InvoiceListFilter, ): Promise { @@ -70,7 +70,7 @@ export const invoicesService = { .then((r) => r.data); }, - /** Confirm a USD invoice paid by bank transfer — the slip file is required. */ + /** Confirm an invoice (USD or ETB) paid manually — the slip file is required. */ confirmOffline(id: string, file: File, reference?: string): Promise { const body = new FormData(); body.append("file", file); diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts index 696a403ee..a266a6257 100644 --- a/apps/edr-freight-web/backoffice/src/types/invoice.ts +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -13,6 +13,8 @@ export interface InvoiceListFilter { companyId?: string; status?: Freight.InvoiceStatus; search?: string; + /** Manual-payments worklist only. */ + currency?: "USD" | "ETB"; } /** Standard paginated list envelope (matches the customers/bookings service shape). */ @@ -22,17 +24,21 @@ export interface PaginatedInvoices { } /** - * A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows - * carry the shipment's pay-window deadline so the list can show the same - * countdown the customer sees — Finance must confirm before it closes. + * A USD or ETB invoice on Finance's manual-settlement worklist. Booking-sourced + * rows carry the shipment's trade direction and pay-window deadline so the list + * can show the same countdown the customer sees — Finance must confirm before + * it closes. */ export interface OfflineUsdInvoice extends Invoice { booking: { id: string; reference: string; + tradeDirection: string | null; paymentDeadline: string | null; paymentStatus: string; } | null; + /** Shipping-line credit invoices span many bookings — one entry per credit. */ + bookings: { id: string; reference: string; tradeDirection: string | null }[]; } export interface PaginatedOfflineUsdInvoices {