mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Enhance manual payment processing for USD and ETB invoices
- Updated API documentation and summaries to reflect support for both USD and ETB invoices. - Modified data structures to include trade direction for invoices. - Adjusted UI components to accommodate manual payment confirmations and display relevant information. - Implemented filtering options for currency in the manual payments worklist.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<Invoice> {
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
<Route
|
||||
path="usd-payments"
|
||||
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
|
||||
element={<Navigate to="/dashboard/invoices?tab=manual-payments" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="invoices/:id"
|
||||
|
||||
@@ -29,13 +29,13 @@ const TABS = [
|
||||
Panel: InvoicesPanel,
|
||||
},
|
||||
{
|
||||
key: "usd-payments",
|
||||
label: "USD Payments",
|
||||
key: "manual-payments",
|
||||
label: "Manual Payments",
|
||||
icon: Landmark,
|
||||
// Same gate as Invoices, not a dedicated key — mirrors the old route.
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
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,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
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)}`;
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
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 (
|
||||
<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() {
|
||||
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<OfflineUsdInvoice | null>(null);
|
||||
const [slip, setSlip] = useState<File | null>(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 }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||
{row.original.company?.name ?? "—"}
|
||||
{row.original.company?.name ??
|
||||
row.original.shippingLineCompany?.name ??
|
||||
"—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -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 (
|
||||
<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) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={<ExternalLink size={13} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||
}}
|
||||
>
|
||||
{booking.reference}
|
||||
</Button>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={<ExternalLink size={13} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||
}}
|
||||
>
|
||||
{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",
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
if (row.original.status === "PAID" || !canConfirm) return null;
|
||||
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() {
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
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
|
||||
size="sm"
|
||||
radius="md"
|
||||
@@ -318,7 +420,7 @@ export default function UsdPaymentsPanel() {
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1040}>
|
||||
<Box miw={1160}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
@@ -326,13 +428,13 @@ export default function UsdPaymentsPanel() {
|
||||
onRowClick={(row) => 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={
|
||||
<Text fw={700}>Confirm bank transfer payment</Text>
|
||||
<Text fw={700}>Confirm manual payment</Text>
|
||||
}
|
||||
radius="md"
|
||||
size="md"
|
||||
@@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() {
|
||||
<Text size="sm" c="dimmed">
|
||||
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.
|
||||
</Text>
|
||||
|
||||
<PhasedFileDropzone
|
||||
label="Bank payment slip"
|
||||
description="PDF or image of the customer's transfer slip."
|
||||
label="Payment slip / receipt"
|
||||
description="PDF or image of the customer's bank transfer slip or payment receipt."
|
||||
value={slip}
|
||||
onChange={setSlip}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Bank reference"
|
||||
description="Optional — the transfer reference from the slip."
|
||||
label="Payment reference"
|
||||
description="Optional — the transfer or receipt reference from the slip."
|
||||
placeholder="e.g. FT24091234567"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.target.value)}
|
||||
|
||||
@@ -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<PaginatedOfflineUsdInvoices> {
|
||||
@@ -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<Invoice> {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user