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 { 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(); 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()]; } }