Merge pull request #1316 from Tria-plc/freight_feature/usermanagement

Enhance manual payment processing for USD and ETB invoices
This commit is contained in:
marshal
2026-08-17 12:13:57 +03:00
committed by GitHub
9 changed files with 242 additions and 78 deletions

View File

@@ -92,7 +92,7 @@ export class BillingController {
@Get("offline-usd") @Get("offline-usd")
@ApiOperation({ @ApiOperation({
summary: 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) { findOfflineUsd(@Query() query: FilterInvoiceDto) {
return this.billingService.findOfflineUsdPaginated(query); return this.billingService.findOfflineUsdPaginated(query);
@@ -104,7 +104,7 @@ export class BillingController {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: 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( confirmOffline(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,

View File

@@ -16,6 +16,7 @@ import { Booking } from "../bookings/entities/booking.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line // Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table. // payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; 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 { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service"; import { CompaniesService } from "../companies/companies.service";
import { EimsInvoiceStatus } from "../eims/eims-registration.types"; import { EimsInvoiceStatus } from "../eims/eims-registration.types";
@@ -48,10 +49,18 @@ export interface PayInvoiceOptions {
export interface OfflineUsdBookingInfo { export interface OfflineUsdBookingInfo {
id: string; id: string;
reference: string; reference: string;
tradeDirection: string | null;
paymentDeadline: Date | null; paymentDeadline: Date | null;
paymentStatus: string; 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. */ /** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput { export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */ /** 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, * Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway), open ones by default or a single status when * never through the gateway) and ETB invoices Finance settles by hand (bank
* filtered. Booking-sourced rows carry the booking's reference and pay-window * transfer / counter) instead of the customer paying online. Open ones by
* deadline so the UI can show the countdown and link to the booking. * 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( async findOfflineUsdPaginated(
filter: { filter: {
status?: Freight.InvoiceStatus; status?: Freight.InvoiceStatus;
search?: string; search?: string;
currency?: "USD" | "ETB";
page?: number; page?: number;
pageSize?: number; pageSize?: number;
} = {}, } = {},
): Promise<{ ): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
total: number;
}> {
const page = filter.page && filter.page > 0 ? filter.page : 1; const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize = const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
@@ -364,11 +374,16 @@ export class BillingService {
.getRepository(Invoice) .getRepository(Invoice)
.createQueryBuilder("invoice") .createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company") .leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) = 'USD'") .where("UPPER(invoice.currency) IN ('USD', 'ETB')")
.orderBy("invoice.issuedAt", "DESC") .orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize) .skip((page - 1) * pageSize)
.take(pageSize); .take(pageSize);
if (filter.currency) {
qb.andWhere("UPPER(invoice.currency) = :currency", {
currency: filter.currency,
});
}
if (filter.status) { if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status }); qb.andWhere("invoice.status = :status", { status: filter.status });
} else { } 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 const bookingIds = items
.filter((i) => i.source === "booking") .filter((i) => i.source === "booking")
@@ -389,11 +405,43 @@ export class BillingService {
const bookings = bookingIds.length const bookings = bookingIds.length
? await this.dataSource.getRepository(Booking).find({ ? await this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) }, 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])); 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 { return {
items: items.map((inv) => { items: items.map((inv) => {
const b = byId.get(inv.sourceId); const b = byId.get(inv.sourceId);
@@ -403,19 +451,22 @@ export class BillingService {
? { ? {
id: b.id, id: b.id,
reference: b.reference, reference: b.reference,
tradeDirection: b.tradeDirection ?? null,
paymentDeadline: b.paymentDeadline ?? null, paymentDeadline: b.paymentDeadline ?? null,
paymentStatus: b.paymentStatus, paymentStatus: b.paymentStatus,
} }
: null, : null,
} as Invoice & { booking: OfflineUsdBookingInfo | null }; bookings: bookingsByInvoice.get(inv.id) ?? [],
} as OfflineUsdInvoiceRow;
}), }),
total, total,
}; };
} }
/** /**
* Finance confirms a USD invoice as paid by bank transfer: stores the slip * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
* against the invoice and settles the FULL outstanding balance through * 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) * {@link recordPayment}, which flips the invoice to PAID and (for bookings)
* emits `booking.invoice.paid` — the same event an online payment fires, so * 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. * the booking advances exactly as if it had been paid through the gateway.
@@ -434,11 +485,6 @@ export class BillingService {
): Promise<Invoice> { ): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId); const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); 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) { if (!file) {
throw new BadRequestException("The bank payment slip file is required."); throw new BadRequestException("The bank payment slip file is required.");
} }

View File

@@ -39,4 +39,11 @@ export class FilterInvoiceDto {
@IsOptional() @IsOptional()
@IsIn(Object.values(Freight.InvoiceStatus)) @IsIn(Object.values(Freight.InvoiceStatus))
status?: 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";
} }

View File

@@ -2414,6 +2414,8 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export, 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, // 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 // 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 // (the cron sweep runs as the system); these are the *manual* exceptional-operations

View File

@@ -315,7 +315,7 @@ const App = () => {
} }
/> />
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via {/* 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 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 tab hides itself if the user lacks the permission it used to be
routed on. */} routed on. */}
@@ -352,7 +352,7 @@ const App = () => {
/> />
<Route <Route
path="usd-payments" path="usd-payments"
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />} element={<Navigate to="/dashboard/invoices?tab=manual-payments" replace />}
/> />
<Route <Route
path="invoices/:id" path="invoices/:id"

View File

@@ -29,13 +29,13 @@ const TABS = [
Panel: InvoicesPanel, Panel: InvoicesPanel,
}, },
{ {
key: "usd-payments", key: "manual-payments",
label: "USD Payments", label: "Manual Payments",
icon: Landmark, icon: Landmark,
// Same gate as Invoices, not a dedicated key — mirrors the old route. // Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view, permission: FREIGHT_PERMS.invoices.view,
subtitle: subtitle:
"USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.", "Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel, Panel: UsdPaymentsPanel,
}, },
] as const; ] as const;

View File

@@ -11,6 +11,7 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
@@ -55,14 +56,19 @@ function formatRemaining(deadlineMs: number, now: number): string | null {
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; : `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
} }
function PayWindowCell({ deadline }: { deadline: string | null }) { /** Ticks once a second while a deadline is set, so window state updates live. */
function useNow(deadline: string | null): number {
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
if (!deadline) return; if (!deadline) return;
const interval = setInterval(() => setNow(Date.now()), 1000); const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [deadline]); }, [deadline]);
return now;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
const now = useNow(deadline);
if (!deadline) { if (!deadline) {
return ( 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 { * "Confirm paid" for one row. Booking invoices are only confirmable while the
const deadline = row.booking?.paymentDeadline; * booking's pay window is open (the API refuses otherwise): no window yet →
return Boolean(deadline && new Date(deadline).getTime() <= Date.now()); * 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 (
<Tooltip
label="Pay window closed — the booking can no longer be confirmed as paid."
disabled={!closed}
withArrow
>
<span>
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={closed}
onClick={(e) => {
e.stopPropagation();
onConfirm(row);
}}
>
Confirm paid
</Button>
</span>
</Tooltip>
);
} }
/** 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() { export default function UsdPaymentsPanel() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -103,6 +152,7 @@ export default function UsdPaymentsPanel() {
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>( const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"", "",
); );
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null); const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null); const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState(""); const [reference, setReference] = useState("");
@@ -119,8 +169,15 @@ export default function UsdPaymentsPanel() {
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
search: debouncedQuery, search: debouncedQuery,
status: statusFilter || undefined, 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( const { data, isLoading, isError, refetch, isFetching } = useQuery(
@@ -170,7 +227,9 @@ export default function UsdPaymentsPanel() {
header: "Customer", header: "Customer",
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}> <Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"} {row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
</Text> </Text>
), ),
}, },
@@ -179,6 +238,28 @@ export default function UsdPaymentsPanel() {
header: "Booking", header: "Booking",
cell: ({ row }) => { cell: ({ row }) => {
const booking = row.original.booking; const booking = row.original.booking;
const bookings = row.original.bookings ?? [];
if (!booking && bookings.length) {
// Shipping-line credit invoice: one link per billed booking.
return (
<Group gap={4} wrap="wrap" maw={280}>
{bookings.map((b) => (
<Button
key={b.id}
variant="subtle"
size="compact-xs"
rightSection={<ExternalLink size={11} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${b.id}`);
}}
>
{b.reference}
</Button>
))}
</Group>
);
}
if (!booking) { if (!booking) {
return ( return (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() {
); );
} }
return ( return (
<Button <Group gap={6} wrap="nowrap">
variant="subtle" <Button
size="compact-sm" variant="subtle"
rightSection={<ExternalLink size={13} />} size="compact-sm"
onClick={(e) => { rightSection={<ExternalLink size={13} />}
e.stopPropagation(); onClick={(e) => {
navigate(`/dashboard/booking-requests/${booking.id}`); e.stopPropagation();
}} navigate(`/dashboard/booking-requests/${booking.id}`);
> }}
{booking.reference} >
</Button> {booking.reference}
</Button>
{booking.tradeDirection && (
<Badge size="xs" variant="light" radius="sm" color="gray">
{humanize(booking.tradeDirection)}
</Badge>
)}
</Group>
); );
}, },
}, },
{
id: "currency",
header: "Currency",
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
radius="sm"
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
>
{row.original.currency}
</Badge>
),
},
{ {
id: "status", id: "status",
header: "Status", header: "Status",
@@ -239,22 +341,8 @@ export default function UsdPaymentsPanel() {
header: "", header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" }, meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => { cell: ({ row }) => {
const paid = row.original.status === "PAID"; if (row.original.status === "PAID" || !canConfirm) return null;
if (paid || !canConfirm) return null; return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
return (
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={windowClosed(row.original)}
onClick={(e) => {
e.stopPropagation();
setConfirming(row.original);
}}
>
Confirm paid
</Button>
);
}, },
}, },
], ],
@@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() {
style={{ flex: 1, minWidth: "240px" }} style={{ flex: 1, minWidth: "240px" }}
radius="lg" radius="lg"
/> />
<SegmentedControl
size="sm"
radius="md"
value={currency || "all"}
onChange={(v) => {
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" },
]}
/>
<SegmentedControl <SegmentedControl
size="sm" size="sm"
radius="md" radius="md"
@@ -318,7 +420,7 @@ export default function UsdPaymentsPanel() {
</Box> </Box>
<Box style={{ overflowX: "auto" }} w="100%"> <Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1040}> <Box miw={1160}>
<DataTable <DataTable
columns={columns} columns={columns}
data={rows} data={rows}
@@ -326,13 +428,13 @@ export default function UsdPaymentsPanel() {
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)} onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={ emptyMessage={
debouncedQuery debouncedQuery
? "No USD invoices match your search." ? "No invoices match your search."
: "No USD invoices awaiting confirmation." : "No invoices awaiting manual payment confirmation."
} }
error={ error={
isError isError
? { ? {
message: "Failed to load USD invoices.", message: "Failed to load invoices.",
onRetry: () => void refetch(), onRetry: () => void refetch(),
} }
: undefined : undefined
@@ -361,7 +463,7 @@ export default function UsdPaymentsPanel() {
opened={confirming !== null} opened={confirming !== null}
onClose={closeConfirm} onClose={closeConfirm}
title={ title={
<Text fw={700}>Confirm bank transfer payment</Text> <Text fw={700}>Confirm manual payment</Text>
} }
radius="md" radius="md"
size="md" size="md"
@@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() {
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Confirming settles {confirming.invoiceNumber} in full ( Confirming settles {confirming.invoiceNumber} in full (
{formatMoney(confirming.balanceAmount, confirming.currency)}) and {formatMoney(confirming.balanceAmount, confirming.currency)}) and
marks the booking as paid. Upload the customer&apos;s bank slip marks the booking as paid exactly as if the customer had paid
first this cannot be undone. online. Upload the customer&apos;s bank slip or receipt first
this cannot be undone.
</Text> </Text>
<PhasedFileDropzone <PhasedFileDropzone
label="Bank payment slip" label="Payment slip / receipt"
description="PDF or image of the customer's transfer slip." description="PDF or image of the customer's bank transfer slip or payment receipt."
value={slip} value={slip}
onChange={setSlip} onChange={setSlip}
/> />
<TextInput <TextInput
label="Bank reference" label="Payment reference"
description="Optional — the transfer reference from the slip." description="Optional — the transfer or receipt reference from the slip."
placeholder="e.g. FT24091234567" placeholder="e.g. FT24091234567"
value={reference} value={reference}
onChange={(e) => setReference(e.target.value)} onChange={(e) => setReference(e.target.value)}

View File

@@ -59,7 +59,7 @@ export const invoicesService = {
.then((r) => r.data); .then((r) => r.data);
}, },
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */ /** Finance worklist: USD and ETB invoices awaiting manual payment confirmation. */
listOfflineUsd( listOfflineUsd(
filter: InvoiceListFilter, filter: InvoiceListFilter,
): Promise<PaginatedOfflineUsdInvoices> { ): Promise<PaginatedOfflineUsdInvoices> {
@@ -70,7 +70,7 @@ export const invoicesService = {
.then((r) => r.data); .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<Invoice> { confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
const body = new FormData(); const body = new FormData();
body.append("file", file); body.append("file", file);

View File

@@ -13,6 +13,8 @@ export interface InvoiceListFilter {
companyId?: string; companyId?: string;
status?: Freight.InvoiceStatus; status?: Freight.InvoiceStatus;
search?: string; search?: string;
/** Manual-payments worklist only. */
currency?: "USD" | "ETB";
} }
/** Standard paginated list envelope (matches the customers/bookings service shape). */ /** 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 * A USD or ETB invoice on Finance's manual-settlement worklist. Booking-sourced
* carry the shipment's pay-window deadline so the list can show the same * rows carry the shipment's trade direction and pay-window deadline so the list
* countdown the customer sees — Finance must confirm before it closes. * can show the same countdown the customer sees — Finance must confirm before
* it closes.
*/ */
export interface OfflineUsdInvoice extends Invoice { export interface OfflineUsdInvoice extends Invoice {
booking: { booking: {
id: string; id: string;
reference: string; reference: string;
tradeDirection: string | null;
paymentDeadline: string | null; paymentDeadline: string | null;
paymentStatus: string; paymentStatus: string;
} | null; } | null;
/** Shipping-line credit invoices span many bookings — one entry per credit. */
bookings: { id: string; reference: string; tradeDirection: string | null }[];
} }
export interface PaginatedOfflineUsdInvoices { export interface PaginatedOfflineUsdInvoices {