mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
108 lines
4.5 KiB
TypeScript
108 lines
4.5 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { DataSource } from 'typeorm';
|
|
import { Freight } from '@edr/types';
|
|
|
|
/** Invoice statuses a customer can still settle (mirrors the portal's PAYABLE_STATUSES). */
|
|
const PAYABLE_INVOICE_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
|
|
/** Booking statuses at which the freight invoice is actually due (mirrors BookingsService). */
|
|
const FREIGHT_PAYABLE_BOOKING_STATUSES = [
|
|
'FULLY_EXECUTED',
|
|
'SELECTED_FOR_BATCH',
|
|
'AWAITING_PAYMENT',
|
|
];
|
|
|
|
/**
|
|
* One row per outstanding item. `invoices.status` / `bookings.status` are
|
|
* Postgres enums, hence the ::text casts. `amount` is NULL for items that only need the
|
|
* customer's review (a proposed clearance charge, a draft final invoice) so
|
|
* they count but do not inflate "amount due".
|
|
*/
|
|
const SQL = `
|
|
-- Central invoices on the booking: freight (only while the booking is in a
|
|
-- payable status), wagon-cancellation fee, GL final invoice (+ its DRAFT,
|
|
-- which waits for the customer's approval).
|
|
SELECT i.source_id AS "bookingId", i.currency,
|
|
CASE WHEN i.status::text = 'DRAFT' THEN NULL ELSE i.balance_amount END AS amount
|
|
FROM freight.invoices i
|
|
JOIN freight.bookings b ON b.id::text = i.source_id AND b.deleted_at IS NULL
|
|
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'booking'
|
|
AND (
|
|
(i.status::text = ANY($2::text[]) AND i.balance_amount > 0
|
|
AND (i.type IN ('WAGON_CANCEL_FEE', 'GL_FINAL') OR b.status::text = ANY($3::text[])))
|
|
OR (i.type = 'GL_FINAL' AND i.status::text = 'DRAFT')
|
|
)
|
|
UNION ALL
|
|
-- Accepted clearance charges whose invoice is still unpaid.
|
|
SELECT c.booking_id::text, i.currency, i.balance_amount
|
|
FROM freight.invoices i
|
|
JOIN freight.booking_clearance_charge c ON c.id::text = i.source_id AND c.deleted_at IS NULL
|
|
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'clearance_charge'
|
|
AND i.status::text = ANY($2::text[]) AND i.balance_amount > 0
|
|
UNION ALL
|
|
-- Clearance charges waiting for the customer to accept or reject the price.
|
|
SELECT c.booking_id::text, c.currency, NULL::numeric
|
|
FROM freight.booking_clearance_charge c
|
|
JOIN freight.bookings b ON b.id = c.booking_id AND b.deleted_at IS NULL
|
|
WHERE b.company_id = $1 AND c.deleted_at IS NULL AND c.status = 'SENT'
|
|
UNION ALL
|
|
-- Duty / tax advised by customs, payment slip not uploaded yet.
|
|
SELECT m.booking_id::text, m.metadata->>'dutyCurrency',
|
|
NULLIF(m.metadata->>'dutyAmount', '')::numeric
|
|
FROM freight.clearance_milestones m
|
|
JOIN freight.bookings b ON b.id = m.booking_id AND b.deleted_at IS NULL
|
|
WHERE b.company_id = $1 AND m.deleted_at IS NULL AND m.status = 'COMPLETED'
|
|
AND (
|
|
(m.milestone_code = 'DUTY_TAXES_ADVISED' AND NOT EXISTS (
|
|
SELECT 1 FROM freight.clearance_milestones p
|
|
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'DUTY_TAX_PAID'
|
|
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
|
|
OR
|
|
(m.milestone_code = 'SECOND_DUTY_ADVISED' AND NOT EXISTS (
|
|
SELECT 1 FROM freight.clearance_milestones p
|
|
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'SECOND_DUTY_PAID'
|
|
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
|
|
)
|
|
`;
|
|
|
|
/**
|
|
* Everything a customer still has to act on, per booking, in one query. Drives
|
|
* the "Pay" badge on the home and booking-list rows; the booking's Payments tab
|
|
* composes the same items client-side from the per-booking endpoints.
|
|
*/
|
|
@Injectable()
|
|
export class BookingPayablesService {
|
|
constructor(private readonly dataSource: DataSource) {}
|
|
|
|
async summarizeForCompany(
|
|
companyId: string,
|
|
): Promise<Freight.BookingPayableSummary[]> {
|
|
const rows: Array<{
|
|
bookingId: string;
|
|
currency: string | null;
|
|
amount: string | null;
|
|
}> = await this.dataSource.query(SQL, [
|
|
companyId,
|
|
PAYABLE_INVOICE_STATUSES,
|
|
FREIGHT_PAYABLE_BOOKING_STATUSES,
|
|
]);
|
|
|
|
const byBooking = new Map<string, Freight.BookingPayableSummary>();
|
|
for (const r of rows) {
|
|
const s = byBooking.get(r.bookingId) ?? {
|
|
bookingId: r.bookingId,
|
|
count: 0,
|
|
totals: [],
|
|
};
|
|
s.count += 1;
|
|
const amount = Number(r.amount ?? 0);
|
|
if (r.currency && amount > 0) {
|
|
const t = s.totals.find((x) => x.currency === r.currency);
|
|
if (t) t.amount += amount;
|
|
else s.totals.push({ currency: r.currency, amount });
|
|
}
|
|
byBooking.set(r.bookingId, s);
|
|
}
|
|
return [...byBooking.values()];
|
|
}
|
|
}
|