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")
@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,

View File

@@ -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.");
}

View File

@@ -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";
}

View File

@@ -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