import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { Invoice } from '../../billing/entities/invoice.entity'; import { invoicePaymentMethodExpr } from '../../billing/invoice-settlement.util'; import { PaymentEntity } from '../../payment/entities/payment.entity'; import { Booking } from '../../bookings/entities/booking.entity'; import { Company } from '../../companies/entities/company.entity'; import { CompanyProfile } from '../../companies/entities/company-profile.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ExportDataset } from '../export.types'; /** Same expression the list endpoint filters by, in this dataset's aliases. */ const PAYMENT_METHOD = invoicePaymentMethodExpr('i', 'p'); /** * Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a * signature blob) and `eims_last_error` (a raw error dump). The * human-meaningful status/IRN/document-number fields are kept. */ export const invoicesDataset: ExportDataset = { key: 'invoices', title: 'Invoices', description: 'Invoices with customer, amounts, payment status and EIMS state', group: 'Finance', permission: FREIGHT_PERMS.invoices.view, base: { entity: Invoice, alias: 'i' }, joins: [ { alias: 'c', entity: Company, on: 'c.id = i.company_id' }, { alias: 'cp', entity: CompanyProfile, on: 'cp.id = i.company_profile_id' }, // No relation object on the entity for this FK — the service hydrates it // with a second query. In a dataset it is just a join by column. { alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' }, { alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' }, // The gateway payment behind the invoice — provider method and its // transaction reference. Always joined: `scope()` filters on it. { alias: 'p', entity: PaymentEntity, on: 'p.id = i.payment_id' }, // Booking behind the invoice, for the PNR alone. `i.source_id` is a bare // varchar pointer that is not always a UUID (EIMS self-test rows carry a // slug), so the cast goes on `bk.id`, never on `source_id` — casting the // other way throws on those rows. { alias: 'bk', entity: Booking, on: "bk.id::text = i.source_id AND i.source = 'booking'" }, ], alwaysJoin: ['c', 'p', 'bk'], groups: [ { id: 'invoice', label: 'Invoice' }, { id: 'customer', label: 'Customer' }, { id: 'amounts', label: 'Amounts' }, { id: 'payment', label: 'Payment' }, { id: 'lines', label: 'Lines' }, { id: 'eims', label: 'EIMS' }, ], fields: [ { key: 'invoiceNumber', label: 'Invoice no.', type: 'string', group: 'invoice', default: true, select: 'i.invoice_number', sortExpr: 'i.invoice_number' }, { key: 'status', label: 'Status', type: 'string', group: 'invoice', default: true, select: 'i.status', sortExpr: 'i.status' }, { key: 'type', label: 'Type', type: 'string', group: 'invoice', select: 'i.type' }, { key: 'source', label: 'Source', type: 'string', group: 'invoice', default: true, select: 'i.source' }, { key: 'sourceId', label: 'Source reference', type: 'string', group: 'invoice', select: 'i.source_id' }, { key: 'issuedAt', label: 'Issued', type: 'date', group: 'invoice', default: true, select: `to_char(i.issued_at, 'YYYY-MM-DD')`, sortExpr: 'i.issued_at' }, { key: 'dueAt', label: 'Due', type: 'date', group: 'invoice', default: true, select: `to_char(i.due_at, 'YYYY-MM-DD')`, sortExpr: 'i.due_at' }, { key: 'createdAt', label: 'Created', type: 'date', group: 'invoice', select: `to_char(i.created_at, 'YYYY-MM-DD')`, sortExpr: 'i.created_at' }, { key: 'relatedInvoice', label: 'Related invoice', type: 'string', group: 'invoice', requires: ['rel'], select: 'rel.invoice_number' }, { key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' }, { key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' }, { key: 'customerVat', label: 'Customer VAT no.', type: 'string', group: 'customer', requires: ['c'], select: 'c.vat_number' }, { key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' }, { key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' }, { key: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' }, { key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' }, { key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' }, { key: 'subtotalAmount', label: 'Subtotal', type: 'money', group: 'amounts', select: 'i.subtotal_amount::float8' }, { key: 'taxAmount', label: 'Tax', type: 'money', group: 'amounts', select: 'i.tax_amount::float8' }, { key: 'totalAmount', label: 'Total', type: 'money', group: 'amounts', default: true, select: 'i.total_amount::float8', sortExpr: 'i.total_amount' }, { key: 'paidAmount', label: 'Paid', type: 'money', group: 'amounts', default: true, select: 'i.paid_amount::float8' }, { key: 'balanceAmount', label: 'Balance', type: 'money', group: 'amounts', default: true, select: 'i.balance_amount::float8', sortExpr: 'i.balance_amount' }, { key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' }, { key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` }, { key: 'paymentMethod', label: 'Payment method', type: 'string', group: 'payment', default: true, requires: ['p'], select: PAYMENT_METHOD, sortExpr: PAYMENT_METHOD }, { key: 'transactionRef', label: 'Transaction ref', type: 'string', group: 'payment', requires: ['p'], select: 'p.transaction_id' }, // The CBE_BILL reference the customer pays against — stamped onto the // booking at payment-initiation time, not held on the invoice or payment. { key: 'pnrCode', label: 'PNR', type: 'string', group: 'payment', requires: ['bk'], select: 'bk.pnr_code' }, { key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', requires: ['p'], select: 'p.status::text' }, { key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment', select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now() THEN EXTRACT(DAY FROM now() - i.due_at)::int ELSE 0 END`, }, { key: 'lineCount', label: 'Line count', type: 'number', group: 'lines', select: `(SELECT COUNT(*)::int FROM freight.invoice_lines il WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`, }, { key: 'lineCharges', label: 'Charges', type: 'string', group: 'lines', select: `(SELECT string_agg(il.charge_type || ': ' || ROUND(il.amount, 2), ' | ' ORDER BY il.charge_type) FROM freight.invoice_lines il WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`, }, { key: 'eimsStatus', label: 'EIMS status', type: 'string', group: 'eims', select: 'i.eims_status' }, { key: 'eimsIrn', label: 'EIMS IRN', type: 'string', group: 'eims', select: 'i.eims_irn' }, { key: 'eimsDocumentNumber', label: 'EIMS document no.', type: 'string', group: 'eims', select: 'i.eims_document_number' }, { key: 'eimsDocumentType', label: 'EIMS document type', type: 'string', group: 'eims', select: 'i.eims_document_type' }, { key: 'eimsSubmittedAt', label: 'EIMS submitted', type: 'datetime', group: 'eims', select: `to_char(i.eims_submitted_at, 'YYYY-MM-DD HH24:MI')` }, // eims_ack_date is varchar in the schema, not a timestamp. { key: 'eimsAckDate', label: 'EIMS acknowledged', type: 'string', group: 'eims', select: 'i.eims_ack_date' }, { key: 'eimsCancelledAt', label: 'EIMS cancelled', type: 'datetime', group: 'eims', select: `to_char(i.eims_cancelled_at, 'YYYY-MM-DD HH24:MI')` }, { key: 'eimsCancellationReasonCode', label: 'EIMS cancellation reason', type: 'string', group: 'eims', select: 'i.eims_cancellation_reason_code' }, ], filters: [ { key: 'issued', label: 'Issued', type: 'daterange' }, { key: 'due', label: 'Due', type: 'daterange' }, { key: 'statuses', label: 'Status', type: 'multiselect' }, // The invoices list page sends a single `status`; accept both so its // on-screen filter actually carries into the export. { key: 'status', label: 'Status (single)', type: 'text' }, { key: 'sources', label: 'Source', type: 'multiselect' }, { key: 'types', label: 'Type', type: 'multiselect' }, { key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' }, { key: 'paymentMethods', label: 'Payment method', type: 'multiselect' }, { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, { value: 'DJF', label: 'DJF' }, ] }, { key: 'minAmount', label: 'Min total', type: 'text' }, { key: 'maxAmount', label: 'Max total', type: 'text' }, { key: 'hasBalance', label: 'Outstanding only', type: 'text' }, { key: 'overdue', label: 'Overdue only', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, { key: 'search', label: 'Search invoice no., customer, PNR or transaction ref', type: 'text' }, ], defaultSort: { key: 'issuedAt', dir: 'DESC' }, scope(ctx, qb) { const { params, directions } = ctx; qb.andWhere('i.deleted_at IS NULL'); if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom }); if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo }); if (params.dueFrom) qb.andWhere('i.due_at >= :dueFrom', { dueFrom: params.dueFrom }); if (params.dueTo) qb.andWhere('i.due_at < :dueTo', { dueTo: params.dueTo }); const statuses = params.statuses as string[] | null; if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses }); if (params.status) qb.andWhere('i.status = :status', { status: params.status }); const sources = params.sources as string[] | null; if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources }); const types = params.types as string[] | null; if (types?.length) qb.andWhere('i.type IN (:...types)', { types }); const eimsStatuses = params.eimsStatuses as string[] | null; if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses }); const paymentMethods = params.paymentMethods as string[] | null; if (paymentMethods?.length) { qb.andWhere(`${PAYMENT_METHOD} IN (:...paymentMethods)`, { paymentMethods }); } // Casing has drifted in the data ("usd" rows exist) — normalise both sides, // same as the list endpoint does. if (params.currency) { qb.andWhere('UPPER(i.currency) = :currency', { currency: String(params.currency).toUpperCase(), }); } if (params.minAmount) qb.andWhere('i.total_amount >= :minAmount', { minAmount: Number(params.minAmount) }); if (params.maxAmount) qb.andWhere('i.total_amount <= :maxAmount', { maxAmount: Number(params.maxAmount) }); if (params.hasBalance === 'true') qb.andWhere('i.balance_amount > 0'); // Computed, not `status = OVERDUE` — nothing sweeps PENDING rows into it. if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()'); if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId }); if (params.search) { // Same reach as the list page's search box, minus the source-record // lookups it does with correlated subqueries: number, customer, the PNR // the customer pays against, and the payment references support desks // quote back. qb.andWhere( `(i.invoice_number ILIKE :search OR c.name ILIKE :search OR bk.pnr_code ILIKE :search OR p.transaction_id ILIKE :search OR p.merchant_order_id ILIKE :search)`, { search: `%${params.search as string}%` }, ); } // ACL: invoices.source_id is a varchar pointer at the originating booking. applyBookingRefDirectionScope(qb, 'i.source_id', directions); }, };