mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 03:40:56 +00:00
fix: invoice filter
This commit is contained in:
@@ -269,7 +269,7 @@ export class BillingService {
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -302,7 +302,9 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
if (filter.sources?.length) {
|
||||
qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources });
|
||||
qb.andWhere("invoice.source IN (:...sources)", {
|
||||
sources: filter.sources,
|
||||
});
|
||||
}
|
||||
if (filter.eimsStatuses?.length) {
|
||||
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
|
||||
@@ -328,7 +330,9 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
if (filter.issuedTo) {
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo });
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", {
|
||||
issuedTo: filter.issuedTo,
|
||||
});
|
||||
}
|
||||
if (filter.dueFrom) {
|
||||
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
|
||||
@@ -591,21 +595,28 @@ export class BillingService {
|
||||
/**
|
||||
* 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.
|
||||
* transfer / counter) instead of the customer paying online. Both currencies
|
||||
* unless `currency` narrows it, and only ones whose manual-payment channel is
|
||||
* switched on. Open ones by default — pin `status` or `statuses` to widen
|
||||
* that. Every other dimension is the invoice list's own (`applyInvoiceFilters`
|
||||
* + `INVOICE_SORT_COLUMNS`), so the two screens filter and sort alike.
|
||||
* 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";
|
||||
filter: InvoiceListFilters & {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
} = {},
|
||||
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
|
||||
): Promise<{
|
||||
items: OfflineUsdInvoiceRow[];
|
||||
total: number;
|
||||
/** Sum of `balanceAmount` over the WHOLE filtered set, by currency. */
|
||||
outstanding: Record<string, number>;
|
||||
}> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
@@ -614,33 +625,75 @@ export class BillingService {
|
||||
// a row Finance cannot act on is noise, and the confirm endpoint would
|
||||
// refuse it anyway. All off → nothing to work.
|
||||
const enabled = await this.manualPaymentSettings.enabledCurrencies();
|
||||
if (!enabled.length) return { items: [], total: 0 };
|
||||
const currencies = filter.currency
|
||||
? enabled.filter((c) => c === filter.currency)
|
||||
: enabled;
|
||||
if (!currencies.length) return { items: [], total: 0 };
|
||||
const empty = { items: [], total: 0, outstanding: {} };
|
||||
if (!enabled.length) return empty;
|
||||
const wanted = filter.currency?.toUpperCase();
|
||||
const currencies = wanted ? enabled.filter((c) => c === wanted) : enabled;
|
||||
if (!currencies.length) return empty;
|
||||
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
/**
|
||||
* The worklist narrows by the same vocabulary as the main invoice list, so
|
||||
* both share `applyInvoiceFilters` — which references the `company` and
|
||||
* `payment` aliases, hence the unconditional joins. `select` is false for
|
||||
* the aggregate pass, where joined columns would break the GROUP BY.
|
||||
*/
|
||||
const buildQb = (select: boolean) => {
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice");
|
||||
if (select) {
|
||||
qb.leftJoinAndSelect("invoice.company", "company").leftJoinAndSelect(
|
||||
"invoice.payment",
|
||||
"payment",
|
||||
);
|
||||
} else {
|
||||
qb.leftJoin("invoice.company", "company").leftJoin(
|
||||
"invoice.payment",
|
||||
"payment",
|
||||
);
|
||||
}
|
||||
qb.where("UPPER(invoice.currency) IN (:...currencies)", { currencies });
|
||||
// "What still needs settling" is the default cut, but only until the
|
||||
// caller pins a status — either the single-status param or the filter
|
||||
// bar's multi-select.
|
||||
if (!filter.status && !filter.statuses?.length) {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
// `currency` is already enforced by the enabled-currency IN above, and
|
||||
// re-applying it would only repeat the same predicate.
|
||||
this.applyInvoiceFilters(qb, { ...filter, currency: undefined });
|
||||
return qb;
|
||||
};
|
||||
|
||||
const qb = buildQb(true)
|
||||
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
|
||||
// raw; the id tiebreaker keeps paging stable when the column ties.
|
||||
.orderBy(
|
||||
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
|
||||
filter.sortOrder ?? "DESC",
|
||||
)
|
||||
.addOrderBy("invoice.id", "ASC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} else {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [rawItems, total] = await qb.getManyAndCount();
|
||||
|
||||
// Outstanding across the whole filtered set, not the visible page — the
|
||||
// KPI must not change as Finance pages through the worklist.
|
||||
const outstandingRows: { currency: string; outstanding: string }[] =
|
||||
await buildQb(false)
|
||||
.select("invoice.currency", "currency")
|
||||
.addSelect("SUM(invoice.balanceAmount)", "outstanding")
|
||||
.groupBy("invoice.currency")
|
||||
.getRawMany();
|
||||
// Folded case-insensitively on the way out: stored casing has drifted
|
||||
// ("usd" rows exist), so two groups can address the same currency.
|
||||
const outstanding: Record<string, number> = {};
|
||||
for (const row of outstandingRows) {
|
||||
const key = (row.currency ?? "").toUpperCase();
|
||||
outstanding[key] =
|
||||
(outstanding[key] ?? 0) + (Number(row.outstanding) || 0);
|
||||
}
|
||||
const items = await this.attachShippingLineCompanies(rawItems);
|
||||
|
||||
const bookingIds = items
|
||||
@@ -704,6 +757,7 @@ export class BillingService {
|
||||
} as OfflineUsdInvoiceRow;
|
||||
}),
|
||||
total,
|
||||
outstanding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -850,7 +904,9 @@ export class BillingService {
|
||||
{
|
||||
label: "Wagons",
|
||||
value:
|
||||
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
|
||||
booking.wagonsRequired != null
|
||||
? String(booking.wagonsRequired)
|
||||
: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -920,11 +976,15 @@ export class BillingService {
|
||||
const eimsCfg = this.config.get<EimsConfig>("eims");
|
||||
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
|
||||
if (eimsCfg?.invoice?.sellerVatNumber) {
|
||||
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
|
||||
summary.push({
|
||||
label: "Seller VAT No.",
|
||||
value: eimsCfg.invoice.sellerVatNumber,
|
||||
});
|
||||
}
|
||||
|
||||
// MoR EIMS reference — only once actually registered, never a placeholder row.
|
||||
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
if (invoice.eimsIrn)
|
||||
summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
|
||||
// The provider's transaction number for the money actually received — CBE's `FT…`,
|
||||
// telebirr's receipt number, or the bank-slip reference a teller recorded manually.
|
||||
@@ -943,7 +1003,8 @@ export class BillingService {
|
||||
where: { id: invoice.sourceId },
|
||||
select: ["id", "pnrCode"],
|
||||
});
|
||||
if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode });
|
||||
if (booking?.pnrCode)
|
||||
summary.push({ label: "PNR", value: booking.pnrCode });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -964,7 +1025,9 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
qrImageUrl: invoice.eimsSignedQr
|
||||
? pngDataUrl(invoice.eimsSignedQr)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1223,7 +1286,9 @@ export class BillingService {
|
||||
metadata: l.metadata ?? null,
|
||||
}));
|
||||
|
||||
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
|
||||
const total = round2(
|
||||
lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0),
|
||||
);
|
||||
if (!(total > 0)) {
|
||||
throw new BadRequestException("A memo must have a positive total.");
|
||||
}
|
||||
@@ -1253,7 +1318,9 @@ export class BillingService {
|
||||
subtotalAmount: total,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
|
||||
...(settled
|
||||
? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() }
|
||||
: {}),
|
||||
},
|
||||
mg,
|
||||
code,
|
||||
@@ -1264,7 +1331,11 @@ export class BillingService {
|
||||
eimsReason: reason,
|
||||
relatedInvoiceId: original.id,
|
||||
...(settled
|
||||
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
|
||||
? {
|
||||
paidAmount: memo.totalAmount,
|
||||
balanceAmount: 0,
|
||||
paidAt: new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, memo.id, patch);
|
||||
@@ -1324,7 +1395,7 @@ export class BillingService {
|
||||
input.dueAt ??
|
||||
new Date(
|
||||
Date.now() +
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
|
||||
@@ -1845,9 +1916,9 @@ export class BillingService {
|
||||
dueAt,
|
||||
...(issuing
|
||||
? {
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch);
|
||||
@@ -1911,10 +1982,7 @@ export class BillingService {
|
||||
const repo = this.dataSource.getRepository(Invoice);
|
||||
const invoices = await repo.findBy({
|
||||
paymentId,
|
||||
status: In([
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
]),
|
||||
status: In([Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending]),
|
||||
});
|
||||
for (const invoice of invoices) {
|
||||
await repo.update(
|
||||
@@ -2091,7 +2159,10 @@ export class BillingService {
|
||||
// Same reference, for an ad-hoc additional charge — its own column, since
|
||||
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
|
||||
// can carry many of these at once.
|
||||
if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) {
|
||||
if (
|
||||
billReference &&
|
||||
invoice.source === Freight.InvoiceSource.AdditionalCharge
|
||||
) {
|
||||
await this.dataSource
|
||||
.getRepository(AdditionalCharge)
|
||||
.update({ id: invoice.sourceId }, { paymentReference: billReference });
|
||||
|
||||
Reference in New Issue
Block a user