mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 07:23:40 +00:00
2476 lines
94 KiB
TypeScript
2476 lines
94 KiB
TypeScript
import { Freight, PaymentReferenceType } from "@edr/types";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import {
|
|
BadRequestException,
|
|
forwardRef,
|
|
Inject,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { EventEmitter2 } from "@nestjs/event-emitter";
|
|
import { logCtx } from "@edr/api-common";
|
|
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
|
|
|
|
import { Booking } from "../bookings/entities/booking.entity";
|
|
import { AdditionalCharge } from "../bookings/entities/additional-charge.entity";
|
|
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.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 { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
|
|
import { EimsConfig } from "../../config/eims.config";
|
|
import { CompaniesService } from "../companies/companies.service";
|
|
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
|
import { FilesService } from "../files/files.service";
|
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
|
import { PaymentService } from "../payment/payment.service";
|
|
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
|
import {
|
|
InvoiceDocumentModel,
|
|
sameCompanyName,
|
|
InvoiceDocumentService,
|
|
pngDataUrl,
|
|
} from "./documents/invoice-document.service";
|
|
import { amountInWords } from "./documents/mor-document.util";
|
|
import { buildEimsSeller, resolveLineTax } from "../eims/eims-invoice-context";
|
|
import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto";
|
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
|
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
|
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
|
|
import {
|
|
applySettlement,
|
|
invoicePaymentMethodExpr,
|
|
round2,
|
|
settlementReferences,
|
|
} from "./invoice-settlement.util";
|
|
import { InvoiceRepository } from "./invoice.repository";
|
|
|
|
/** Options forwarded to the payment gateway when settling an invoice. */
|
|
export interface PayInvoiceOptions {
|
|
method?: string;
|
|
platform?: "web" | "mobile";
|
|
payerAccount?: string;
|
|
returnUrl?: string;
|
|
failureUrl?: string;
|
|
}
|
|
|
|
/**
|
|
* What an invoice's `sourceId` actually points at, resolved for display.
|
|
*
|
|
* `source` alone ("warehouse", "booking", …) says which subsystem raised the
|
|
* invoice but nothing about *which* record, and `sourceId` is a raw UUID. Every
|
|
* source except a shipping-line credit hangs off a booking — directly
|
|
* (booking/clearance) or through the warehouse/first-mile/last-mile record —
|
|
* so the booking reference is the one label that identifies almost any row.
|
|
*/
|
|
export interface InvoiceSourceRef {
|
|
/** Booking behind the invoice, when there is one. Null for shipping-line credits. */
|
|
bookingId: string | null;
|
|
bookingReference: string | null;
|
|
tradeDirection: string | null;
|
|
/** Warehouse-sourced rows: the goods-received note the fees were raised against. */
|
|
grnNumber: string | null;
|
|
/** Shipping-line credit rows: `sourceId` is the line's own id, not a record's. */
|
|
shippingLineName: string | null;
|
|
}
|
|
|
|
/** Row shape of the backoffice invoice list: the entity plus its resolved source. */
|
|
export type InvoiceListRow = Invoice & { sourceRef: InvoiceSourceRef | null };
|
|
|
|
/** Booking context attached to a finance offline-USD invoice row. */
|
|
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. */
|
|
amount: number;
|
|
method?: string | null;
|
|
reference?: string | null;
|
|
/** When the settlement occurred; defaults to now. */
|
|
paidAt?: Date;
|
|
metadata?: Record<string, unknown> | null;
|
|
}
|
|
|
|
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
|
/**
|
|
* Every dimension the backoffice invoice list narrows by. `findAllPaginated`
|
|
* and `collectedSummary` share it so the summary card can never total a
|
|
* different set of invoices than the table below it shows.
|
|
*/
|
|
export interface InvoiceListFilters {
|
|
companyId?: string;
|
|
status?: Freight.InvoiceStatus;
|
|
statuses?: Freight.InvoiceStatus[];
|
|
sources?: string[];
|
|
/** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */
|
|
types?: string[];
|
|
eimsStatuses?: string[];
|
|
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
|
|
paymentMethods?: string[];
|
|
currency?: string;
|
|
search?: string;
|
|
issuedFrom?: string;
|
|
issuedTo?: string;
|
|
dueFrom?: string;
|
|
dueTo?: string;
|
|
minAmount?: number;
|
|
maxAmount?: number;
|
|
hasBalance?: boolean;
|
|
overdue?: boolean;
|
|
/** Per-user trade-direction scope, applied via the source booking. */
|
|
tradeDirections?: string[];
|
|
}
|
|
|
|
/**
|
|
* The list/summary query builders both alias the invoice as `invoice` and the
|
|
* joined gateway payment as `payment`; TypeORM rewrites those alias.property
|
|
* references into real quoted columns.
|
|
*/
|
|
const PAYMENT_METHOD_EXPR = invoicePaymentMethodExpr("invoice", "payment");
|
|
|
|
const DEFAULT_DUE_DAYS = 14;
|
|
|
|
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
|
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
|
Freight.InvoiceStatus.Issued,
|
|
Freight.InvoiceStatus.Pending,
|
|
// Success-redirect ack; still unsettled, so it must stay payable/settleable.
|
|
Freight.InvoiceStatus.PaymentProcessing,
|
|
Freight.InvoiceStatus.PartiallyPaid,
|
|
Freight.InvoiceStatus.Overdue,
|
|
];
|
|
|
|
/**
|
|
* Why a non-open invoice can no longer be paid, in the vocabulary the payment service's CBE
|
|
* bill-query mapper understands. Kept specific: CBE reads this back to the payer at the counter,
|
|
* so "cancelled" must not stand in for "already paid" or "refunded".
|
|
*/
|
|
function closedInvoiceReason(status: Freight.InvoiceStatus): string {
|
|
switch (status) {
|
|
case Freight.InvoiceStatus.Paid:
|
|
return "ALREADY_PAID";
|
|
case Freight.InvoiceStatus.Refunded:
|
|
return "REFUNDED";
|
|
case Freight.InvoiceStatus.Cancelled:
|
|
return "CANCELLED";
|
|
case Freight.InvoiceStatus.Expired:
|
|
return "EXPIRED";
|
|
// Draft — issued to nobody yet, so there is nothing honest to say beyond "not payable".
|
|
default:
|
|
return "NOT_PAYABLE";
|
|
}
|
|
}
|
|
|
|
/** A single line to bill on a generated invoice. */
|
|
export interface InvoiceLineInput {
|
|
chargeType: string;
|
|
description?: string;
|
|
/** Units this line bills for; defaults to 1. */
|
|
quantity?: number;
|
|
/** Price per unit; defaults to 0. */
|
|
unitRate?: number;
|
|
/** Line total; defaults to `quantity * unitRate`. */
|
|
amount?: number;
|
|
currency?: string;
|
|
metadata?: Record<string, unknown> | null;
|
|
}
|
|
|
|
/** Everything needed to generate an invoice for any source. */
|
|
export interface GenerateInvoiceInput {
|
|
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
|
|
source: Freight.InvoiceSource;
|
|
/** Identifier of the source record (e.g. booking id). */
|
|
sourceId: string;
|
|
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
|
type: string;
|
|
/** The customer billed. Omit only when billing a shipping line instead. */
|
|
companyId?: string | null;
|
|
companyProfileId?: string | null;
|
|
/**
|
|
* The shipping line billed, for an invoice covering batched shipping-line
|
|
* credits. Mutually exclusive with `companyId` — the DB enforces this via
|
|
* `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload
|
|
* setting both or neither before it ever reaches the constraint.
|
|
*/
|
|
shippingLineCompanyId?: string | null;
|
|
lines: InvoiceLineInput[];
|
|
currency?: string;
|
|
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
|
|
subtotalAmount?: number;
|
|
/** Tax applied on top of the subtotal; defaults to 0. */
|
|
taxAmount?: number;
|
|
/** Explicit total; defaults to `subtotalAmount + taxAmount`. */
|
|
totalAmount?: number;
|
|
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
|
|
dueAt?: Date;
|
|
dueInDays?: number;
|
|
/**
|
|
* Initial status. DRAFT leaves `issuedAt` null; any issued status
|
|
* (default PENDING) stamps `issuedAt`.
|
|
*/
|
|
status?: Freight.InvoiceStatus;
|
|
}
|
|
|
|
/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */
|
|
export type MemoType = "CRE" | "DEB";
|
|
|
|
/** Everything needed to issue a credit or debit memo against an already-registered invoice. */
|
|
export interface IssueMemoInput {
|
|
type: MemoType;
|
|
/** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */
|
|
reason: string;
|
|
/** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */
|
|
lines?: InvoiceLineInput[];
|
|
}
|
|
|
|
/** Payload broadcast on `${source}.invoice.<event>`. */
|
|
export interface InvoiceEventPayload {
|
|
invoiceId: string;
|
|
invoiceNumber: string;
|
|
source: Freight.InvoiceSource;
|
|
sourceId: string;
|
|
type: string;
|
|
/** Null when the payer is a shipping line rather than a customer company. */
|
|
companyId: string | null;
|
|
companyProfileId: string | null;
|
|
/** Set only on shipping-line invoices; mutually exclusive with `companyId`. */
|
|
shippingLineCompanyId?: string | null;
|
|
totalAmount: number;
|
|
currency: string;
|
|
status: Freight.InvoiceStatus;
|
|
paymentId?: string | null;
|
|
}
|
|
|
|
@Injectable()
|
|
export class BillingService {
|
|
private readonly logger = new Logger(BillingService.name);
|
|
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly invoices: InvoiceRepository,
|
|
private readonly invoiceLines: InvoiceLineRepository,
|
|
private readonly events: EventEmitter2,
|
|
@Inject(forwardRef(() => PaymentService))
|
|
private readonly payment: PaymentService,
|
|
private readonly companies: CompaniesService,
|
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
|
private readonly files: FilesService,
|
|
private readonly config: ConfigService,
|
|
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
|
) {}
|
|
|
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
|
|
|
/** List every invoice (most recent first). */
|
|
findAll(): Promise<Invoice[]> {
|
|
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
|
}
|
|
|
|
/**
|
|
* Paginated invoice list for the backoffice — optionally narrowed to a
|
|
* company (customer detail "Invoices" tab) and/or status/search (global
|
|
* invoices page).
|
|
*/
|
|
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
|
|
private applyInvoiceFilters(
|
|
qb: SelectQueryBuilder<Invoice>,
|
|
filter: InvoiceListFilters,
|
|
) {
|
|
if (filter.companyId) {
|
|
qb.andWhere("invoice.companyId = :companyId", {
|
|
companyId: filter.companyId,
|
|
});
|
|
}
|
|
if (filter.status) {
|
|
qb.andWhere("invoice.status = :status", { status: filter.status });
|
|
}
|
|
if (filter.statuses?.length) {
|
|
qb.andWhere("invoice.status IN (:...statuses)", {
|
|
statuses: filter.statuses,
|
|
});
|
|
}
|
|
if (filter.sources?.length) {
|
|
qb.andWhere("invoice.source IN (:...sources)", {
|
|
sources: filter.sources,
|
|
});
|
|
}
|
|
if (filter.types?.length) {
|
|
qb.andWhere("invoice.type IN (:...types)", { types: filter.types });
|
|
}
|
|
if (filter.eimsStatuses?.length) {
|
|
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
|
|
eimsStatuses: filter.eimsStatuses,
|
|
});
|
|
}
|
|
if (filter.paymentMethods?.length) {
|
|
// Requires the `payment` alias to be joined by the caller — both call
|
|
// sites do, unconditionally, so this can never reference a missing alias.
|
|
qb.andWhere(`${PAYMENT_METHOD_EXPR} IN (:...paymentMethods)`, {
|
|
paymentMethods: filter.paymentMethods,
|
|
});
|
|
}
|
|
if (filter.currency) {
|
|
// Stored casing has drifted ("usd" rows exist) — compare normalised.
|
|
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
|
currency: filter.currency.toUpperCase(),
|
|
});
|
|
}
|
|
if (filter.issuedFrom) {
|
|
qb.andWhere("invoice.issuedAt >= :issuedFrom", {
|
|
issuedFrom: filter.issuedFrom,
|
|
});
|
|
}
|
|
if (filter.issuedTo) {
|
|
qb.andWhere("invoice.issuedAt <= :issuedTo", {
|
|
issuedTo: filter.issuedTo,
|
|
});
|
|
}
|
|
if (filter.dueFrom) {
|
|
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
|
|
}
|
|
if (filter.dueTo) {
|
|
qb.andWhere("invoice.dueAt <= :dueTo", { dueTo: filter.dueTo });
|
|
}
|
|
if (filter.minAmount !== undefined) {
|
|
qb.andWhere("invoice.totalAmount >= :minAmount", {
|
|
minAmount: filter.minAmount,
|
|
});
|
|
}
|
|
if (filter.maxAmount !== undefined) {
|
|
qb.andWhere("invoice.totalAmount <= :maxAmount", {
|
|
maxAmount: filter.maxAmount,
|
|
});
|
|
}
|
|
if (filter.hasBalance) {
|
|
qb.andWhere("invoice.balanceAmount > 0");
|
|
}
|
|
if (filter.overdue) {
|
|
// Computed, not `status = OVERDUE`: nothing sweeps PENDING rows into
|
|
// that status, so reading the column alone under-reports the arrears.
|
|
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
|
|
}
|
|
if (filter.search) {
|
|
// Searches what the row actually shows: its number, who it bills, the
|
|
// source record behind it (booking reference, PNR, GRN, shipping line)
|
|
// and the payment references a customer or a provider support desk would
|
|
// quote back — the gateway transaction id and our merchant order id.
|
|
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
|
|
// Requires the `company` and `payment` aliases — every caller joins both.
|
|
qb.andWhere(
|
|
`(invoice.invoiceNumber ILIKE :search
|
|
OR invoice.sourceId ILIKE :search
|
|
OR company.name ILIKE :search
|
|
OR payment.transactionId ILIKE :search
|
|
OR payment.merchantOrderId ILIKE :search
|
|
OR EXISTS (
|
|
SELECT 1 FROM freight.bookings b
|
|
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
|
|
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
|
|
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
|
|
WHERE (b.reference ILIKE :search OR b.pnr_code ILIKE :search)
|
|
AND (b.id::text = invoice.source_id
|
|
OR wi.id::text = invoice.source_id
|
|
OR fm.id::text = invoice.source_id
|
|
OR lm.id::text = invoice.source_id))
|
|
OR EXISTS (
|
|
SELECT 1 FROM freight.warehouse_inventory wi2
|
|
WHERE wi2.id::text = invoice.source_id
|
|
AND wi2.grn_number ILIKE :search)
|
|
OR EXISTS (
|
|
SELECT 1 FROM freight.shipping_line_companies slc
|
|
WHERE slc.id::text = invoice.source_id
|
|
AND slc.name ILIKE :search))`,
|
|
{ search: `%${filter.search}%` },
|
|
);
|
|
}
|
|
if (filter.tradeDirections) {
|
|
applyBookingRefDirectionScope(
|
|
qb,
|
|
"invoice.source_id",
|
|
filter.tradeDirections,
|
|
);
|
|
}
|
|
return qb;
|
|
}
|
|
|
|
async findAllPaginated(
|
|
filter: InvoiceListFilters & {
|
|
page?: number;
|
|
pageSize?: number;
|
|
sortBy?: string;
|
|
sortOrder?: "ASC" | "DESC";
|
|
} = {},
|
|
): Promise<{ items: InvoiceListRow[]; total: number }> {
|
|
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
|
const pageSize =
|
|
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
|
|
|
const qb = this.dataSource
|
|
.getRepository(Invoice)
|
|
.createQueryBuilder("invoice")
|
|
.leftJoinAndSelect("invoice.company", "company")
|
|
// The gateway payment behind the invoice: the settled method and the
|
|
// provider's transaction reference both live on it, and nowhere else.
|
|
.leftJoinAndSelect("invoice.payment", "payment")
|
|
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
|
|
// raw. The id tiebreaker keeps paging stable when the sort column ties
|
|
// (issuedAt is null on every DRAFT row).
|
|
.orderBy(
|
|
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
|
|
filter.sortOrder ?? "DESC",
|
|
)
|
|
.addOrderBy("invoice.id", "ASC")
|
|
.skip((page - 1) * pageSize)
|
|
.take(pageSize);
|
|
|
|
this.applyInvoiceFilters(qb, filter);
|
|
|
|
const [items, total] = await qb.getManyAndCount();
|
|
const withLines = await this.attachShippingLineCompanies(items);
|
|
return { items: await this.attachSourceRefs(withLines), total };
|
|
}
|
|
|
|
/**
|
|
* Resolve each row's `sourceId` to the record it points at, in one query for
|
|
* the whole page. `sourceId` is a bare varchar pointer with no FK and no
|
|
* relation to eager-load, and which table it addresses depends on `source` —
|
|
* so this walks every candidate table at once and lands on the booking
|
|
* through whichever one matched.
|
|
*
|
|
* `sourceId` is not always a UUID (EIMS self-test rows carry a slug), hence
|
|
* the shape guard before every cast — an unguarded `::uuid` throws on those.
|
|
*/
|
|
private async attachSourceRefs<T extends Invoice>(
|
|
invoices: T[],
|
|
): Promise<(T & { sourceRef: InvoiceSourceRef | null })[]> {
|
|
const sourceIds = [
|
|
...new Set(invoices.map((i) => i.sourceId).filter(Boolean)),
|
|
];
|
|
if (!sourceIds.length) {
|
|
return invoices.map((invoice) => ({ ...invoice, sourceRef: null }));
|
|
}
|
|
|
|
const rows: {
|
|
sourceId: string;
|
|
bookingId: string | null;
|
|
bookingReference: string | null;
|
|
tradeDirection: string | null;
|
|
grnNumber: string | null;
|
|
shippingLineName: string | null;
|
|
}[] = await this.dataSource.query(
|
|
`SELECT s.source_id AS "sourceId",
|
|
b.id::text AS "bookingId",
|
|
b.reference AS "bookingReference",
|
|
b.trade_direction AS "tradeDirection",
|
|
wi.grn_number AS "grnNumber",
|
|
slc.name AS "shippingLineName"
|
|
FROM unnest($1::text[]) AS s(source_id)
|
|
LEFT JOIN freight.warehouse_inventory wi
|
|
ON wi.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
|
THEN s.source_id::uuid END)
|
|
AND wi.deleted_at IS NULL
|
|
LEFT JOIN freight.first_mile fm
|
|
ON fm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
|
THEN s.source_id::uuid END)
|
|
AND fm.deleted_at IS NULL
|
|
LEFT JOIN freight.last_mile lm
|
|
ON lm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
|
THEN s.source_id::uuid END)
|
|
AND lm.deleted_at IS NULL
|
|
LEFT JOIN freight.bookings b
|
|
ON b.id = COALESCE(wi.booking_id, fm.booking_id, lm.booking_id,
|
|
CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
|
THEN s.source_id::uuid END)
|
|
AND b.deleted_at IS NULL
|
|
LEFT JOIN freight.shipping_line_companies slc
|
|
ON slc.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
|
THEN s.source_id::uuid END)
|
|
AND slc.deleted_at IS NULL`,
|
|
[sourceIds],
|
|
);
|
|
|
|
const bySourceId = new Map(rows.map((r) => [r.sourceId, r]));
|
|
return invoices.map((invoice) => {
|
|
const row = bySourceId.get(invoice.sourceId);
|
|
const sourceRef: InvoiceSourceRef | null = row
|
|
? {
|
|
bookingId: row.bookingId,
|
|
bookingReference: row.bookingReference,
|
|
tradeDirection: row.tradeDirection,
|
|
grnNumber: row.grnNumber,
|
|
shippingLineName: row.shippingLineName,
|
|
}
|
|
: null;
|
|
// Nothing resolved (an EIMS self-test row, a deleted record) → null,
|
|
// and the UI falls back to the plain source label.
|
|
const resolved =
|
|
sourceRef &&
|
|
(sourceRef.bookingId ||
|
|
sourceRef.grnNumber ||
|
|
sourceRef.shippingLineName)
|
|
? sourceRef
|
|
: null;
|
|
return { ...invoice, sourceRef: resolved };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping
|
|
* line (`companyId` null). No relation on `Invoice` to eager-load — see the
|
|
* entity's doc comment — so this is a second query keyed off the ids
|
|
* already loaded, same shape as `company`.
|
|
*/
|
|
private async attachShippingLineCompanies<T extends Invoice>(
|
|
invoices: T[],
|
|
): Promise<T[]> {
|
|
const ids = [
|
|
...new Set(
|
|
invoices
|
|
.map((i) => i.shippingLineCompanyId)
|
|
.filter((id): id is string => id != null),
|
|
),
|
|
];
|
|
if (!ids.length) return invoices;
|
|
const lines = await this.dataSource
|
|
.getRepository(ShippingLineCompany)
|
|
.find({ where: { id: In(ids) } });
|
|
const byId = new Map(lines.map((l) => [l.id, l]));
|
|
return invoices.map((invoice) => {
|
|
const line = invoice.shippingLineCompanyId
|
|
? byId.get(invoice.shippingLineCompanyId)
|
|
: undefined;
|
|
return line
|
|
? ({
|
|
...invoice,
|
|
shippingLineCompany: {
|
|
id: line.id,
|
|
name: line.name,
|
|
email: line.email,
|
|
phoneNumber: line.phoneNumber,
|
|
},
|
|
} as T)
|
|
: invoice;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Total collected (`paidAmount`) across every invoice matching the same
|
|
* filters as `findAllPaginated`, grouped by currency — unpaginated, so the
|
|
* invoices summary card reflects the whole filtered set, not just the
|
|
* visible page.
|
|
*/
|
|
async collectedSummary(
|
|
filter: InvoiceListFilters = {},
|
|
): Promise<Record<string, number>> {
|
|
const qb = this.dataSource
|
|
.getRepository(Invoice)
|
|
.createQueryBuilder("invoice")
|
|
// Joined, not selected: `applyInvoiceFilters` searches the customer name,
|
|
// so the alias has to exist even though the summary only sums money.
|
|
.leftJoin("invoice.company", "company")
|
|
.leftJoin("invoice.payment", "payment")
|
|
.select("invoice.currency", "currency")
|
|
.addSelect("SUM(invoice.paidAmount)", "collected")
|
|
.groupBy("invoice.currency");
|
|
|
|
this.applyInvoiceFilters(qb, filter);
|
|
|
|
const rows: { currency: string; collected: string }[] =
|
|
await qb.getRawMany();
|
|
|
|
return Object.fromEntries(
|
|
rows.map((row) => [row.currency, Number(row.collected) || 0]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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. 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: InvoiceListFilters & {
|
|
page?: number;
|
|
pageSize?: number;
|
|
sortBy?: string;
|
|
sortOrder?: "ASC" | "DESC";
|
|
} = {},
|
|
): 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;
|
|
|
|
// Only currencies whose manual-payment channel is switched on are listed:
|
|
// 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();
|
|
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;
|
|
|
|
/**
|
|
* 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);
|
|
|
|
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
|
|
.filter((i) => i.source === "booking")
|
|
.map((i) => i.sourceId);
|
|
const bookings = bookingIds.length
|
|
? await this.dataSource.getRepository(Booking).find({
|
|
where: { id: In(bookingIds) },
|
|
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);
|
|
return {
|
|
...inv,
|
|
booking: b
|
|
? {
|
|
id: b.id,
|
|
reference: b.reference,
|
|
tradeDirection: b.tradeDirection ?? null,
|
|
paymentDeadline: b.paymentDeadline ?? null,
|
|
paymentStatus: b.paymentStatus,
|
|
}
|
|
: null,
|
|
bookings: bookingsByInvoice.get(inv.id) ?? [],
|
|
} as OfflineUsdInvoiceRow;
|
|
}),
|
|
total,
|
|
outstanding,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
|
|
* or counter payment. Refused when that currency's manual-payment channel is
|
|
* switched off in settings. 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.
|
|
*
|
|
* Guarded by the booking's pay window: past the deadline the booking expires
|
|
* like any unpaid one, so confirmation is refused.
|
|
*/
|
|
async confirmOfflinePayment(
|
|
invoiceId: string,
|
|
file: Express.Multer.File | undefined,
|
|
input: {
|
|
reference?: string | null;
|
|
userId?: string | null;
|
|
userName?: string | null;
|
|
},
|
|
): Promise<Invoice> {
|
|
const invoice = await this.invoices.findById(invoiceId);
|
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
|
// The channel is a setting, not a role: even a permitted user cannot
|
|
// settle by hand in a currency whose channel is switched off.
|
|
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
|
|
throw new BadRequestException(
|
|
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`,
|
|
);
|
|
}
|
|
if (!file) {
|
|
throw new BadRequestException("The bank payment slip file is required.");
|
|
}
|
|
|
|
// The pay window belongs to the freight invoice. A wagon-cancellation fee
|
|
// rides source=booking but is raised on an ALREADY-PAID booking, so it
|
|
// inherits a deadline that has long passed — guarding it would make the fee
|
|
// permanently unsettleable.
|
|
if (
|
|
invoice.source === "booking" &&
|
|
invoice.type !== WAGON_CANCEL_FEE_INVOICE_TYPE
|
|
) {
|
|
const booking = await this.dataSource.getRepository(Booking).findOne({
|
|
where: { id: invoice.sourceId },
|
|
select: ["id", "paymentDeadline"],
|
|
});
|
|
const deadline = booking?.paymentDeadline;
|
|
if (deadline && new Date(deadline).getTime() < Date.now()) {
|
|
throw new BadRequestException(
|
|
"The payment window has closed — this booking can no longer be confirmed as paid.",
|
|
);
|
|
}
|
|
}
|
|
|
|
const slip = await this.files.upload({
|
|
resource: "invoice",
|
|
resourceId: invoice.id,
|
|
code: "OFFLINE_PAYMENT_SLIP",
|
|
file,
|
|
title: "Bank payment slip",
|
|
uploadedByUserId: input.userId ?? null,
|
|
uploadedByName: input.userName ?? null,
|
|
});
|
|
|
|
return this.recordPayment(invoiceId, {
|
|
amount: Number(invoice.balanceAmount),
|
|
method: "BANK_TRANSFER",
|
|
reference: input.reference || slip.name,
|
|
metadata: {
|
|
offline: true,
|
|
slipFileId: slip.id,
|
|
confirmedByUserId: input.userId ?? null,
|
|
confirmedByName: input.userName ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Invoice header plus its line items. */
|
|
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
|
const invoice = await this.invoices.findById(id, {
|
|
relations: { company: true, companyProfile: true, payment: true },
|
|
});
|
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
|
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
|
|
const lines = await this.invoiceLines.findAll({
|
|
where: { invoiceId: id },
|
|
order: { createdAt: "ASC" },
|
|
});
|
|
return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] };
|
|
}
|
|
|
|
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Sealed PDF invoice for any source, rendered by the shared document service. `format`
|
|
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
|
|
* boundary check, not a business rule.
|
|
*/
|
|
async document(
|
|
id: string,
|
|
format: "a4" | "thermal" = "a4",
|
|
): Promise<{ filename: string; buffer: Buffer }> {
|
|
const invoice = await this.findById(id);
|
|
const model = await this.toDocumentModel(invoice, "INVOICE");
|
|
return format === "thermal"
|
|
? this.invoiceDocuments.renderThermal(model)
|
|
: this.invoiceDocuments.render(model);
|
|
}
|
|
|
|
/** Sealed PDF receipt; available once any payment has been recorded. */
|
|
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
|
const invoice = await this.findById(id);
|
|
if (Number(invoice.paidAmount) <= 0) {
|
|
throw new BadRequestException(
|
|
"A receipt is available only after payment is recorded.",
|
|
);
|
|
}
|
|
return this.invoiceDocuments.render(
|
|
await this.toDocumentModel(invoice, "RECEIPT"),
|
|
);
|
|
}
|
|
|
|
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
|
|
private async bookingSummaryRows(
|
|
invoice: Invoice,
|
|
): Promise<InvoiceDocumentModel["summary"]> {
|
|
if (invoice.source !== Freight.InvoiceSource.Booking) return [];
|
|
const booking = await this.dataSource.getRepository(Booking).findOne({
|
|
where: { id: invoice.sourceId },
|
|
relations: { originYard: true, destinationYard: true },
|
|
});
|
|
if (!booking) return [];
|
|
return [
|
|
{
|
|
label: "Route",
|
|
value:
|
|
booking.originYard && booking.destinationYard
|
|
? `${booking.originYard.label} → ${booking.destinationYard.label}`
|
|
: null,
|
|
},
|
|
{
|
|
label: "Wagons",
|
|
value:
|
|
booking.wagonsRequired != null
|
|
? String(booking.wagonsRequired)
|
|
: null,
|
|
},
|
|
];
|
|
}
|
|
|
|
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
|
private async toDocumentModel(
|
|
invoice: Invoice & { lines: InvoiceLine[] },
|
|
kind: "INVOICE" | "RECEIPT",
|
|
): Promise<InvoiceDocumentModel> {
|
|
const title = invoice.source
|
|
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
|
|
: "EDR";
|
|
const totals: InvoiceDocumentModel["totals"] = [
|
|
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
|
|
];
|
|
if (Number(invoice.taxAmount) > 0) {
|
|
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
|
|
}
|
|
totals.push({
|
|
label: "Total",
|
|
amount: Number(invoice.totalAmount),
|
|
grand: true,
|
|
});
|
|
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
|
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
|
|
|
const tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim();
|
|
|
|
const summary: InvoiceDocumentModel["summary"] = [
|
|
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
|
|
// filed against, not just the seller. VatNumber shown only when the company has one.
|
|
{ label: "Buyer", value: invoice.company?.name ?? null },
|
|
// The trade name of the eTrade licence THIS profile operates as. A TIN
|
|
// holds many licences and the invoiced role (importer/exporter/forwarder)
|
|
// is usually a different business from the one the company registered
|
|
// under, so the buyer's name alone doesn't say which one was billed.
|
|
// Suppressed when it just repeats the buyer name — most companies trade
|
|
// under their registered name and a duplicate row helps nobody.
|
|
...(tradeName && !sameCompanyName(tradeName, invoice.company?.name)
|
|
? [{ label: "Buyer trade name", value: tradeName }]
|
|
: []),
|
|
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
|
|
...(invoice.company?.vatNumber
|
|
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
|
|
: []),
|
|
{ label: "Status", value: invoice.status },
|
|
{ label: "Type", value: invoice.type },
|
|
{ label: "Reference", value: invoice.sourceId },
|
|
...(await this.bookingSummaryRows(invoice)),
|
|
{ label: "Currency", value: invoice.currency },
|
|
{
|
|
label: "Issued",
|
|
value: invoice.issuedAt
|
|
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
|
: null,
|
|
},
|
|
{
|
|
label: "Due",
|
|
value: invoice.dueAt
|
|
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
|
: null,
|
|
},
|
|
];
|
|
|
|
// Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this
|
|
// codebase). Shown only when actually configured, same as the buyer VAT row.
|
|
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,
|
|
});
|
|
}
|
|
|
|
// MoR EIMS reference — only once actually registered, never a placeholder row.
|
|
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.
|
|
// It is what a payer holding a receipt can match this invoice against, and what
|
|
// finance reconciles a bank statement with; without it a PAID invoice proves only
|
|
// that EDR says it was paid. `findById` already loads the `payment` relation, so both
|
|
// sources are in hand here — see settlementReferences for why both are read.
|
|
const txnRefs = settlementReferences(invoice);
|
|
if (txnRefs) summary.push({ label: "Transaction ref", value: txnRefs });
|
|
|
|
// PNR — the CBE_BILL reference the customer pays against, written onto the booking at
|
|
// payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so
|
|
// look it up by source id; only shown once a payment actually generated one.
|
|
if (invoice.source === Freight.InvoiceSource.Booking) {
|
|
const booking = await this.dataSource.getRepository(Booking).findOne({
|
|
where: { id: invoice.sourceId },
|
|
select: ["id", "pnrCode"],
|
|
});
|
|
if (booking?.pnrCode)
|
|
summary.push({ label: "PNR", value: booking.pnrCode });
|
|
}
|
|
|
|
return {
|
|
kind,
|
|
title,
|
|
documentNumber: invoice.invoiceNumber,
|
|
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
|
status: invoice.status,
|
|
currency: invoice.currency,
|
|
summary,
|
|
categoryHeader: "Charge type",
|
|
lines: invoice.lines.map((l) => {
|
|
// Same resolver the filing used, so the printed Tax Code / Excise / Discount columns
|
|
// state what MoR actually holds for this line.
|
|
const tax = eimsCfg?.invoice ? resolveLineTax(eimsCfg, l.chargeType) : null;
|
|
return {
|
|
description: l.description ?? l.chargeType,
|
|
category: l.chargeType,
|
|
quantity: l.quantity,
|
|
unitRate: l.unitRate,
|
|
amount: l.amount,
|
|
currency: l.currency,
|
|
nature: eimsCfg?.invoice?.natureOfSupplies ?? null,
|
|
uom: eimsCfg?.invoice?.unitDefault ?? null,
|
|
taxCode: tax?.code ?? null,
|
|
excise: tax?.exciseTaxValue ?? null,
|
|
discount: tax?.discount ?? null,
|
|
};
|
|
}),
|
|
totals,
|
|
qrImageUrl: invoice.eimsSignedQr
|
|
? pngDataUrl(invoice.eimsSignedQr)
|
|
: null,
|
|
mor: eimsCfg?.invoice ? this.buildMorDetails(invoice, eimsCfg) : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The MoR tax-document view of an invoice (ADD-P001) — the bilingual layout a customer also sees
|
|
* when they scan the QR on the Ministry's portal.
|
|
*
|
|
* Built from the invoice plus EIMS configuration alone, never from a live EIMS call: a document
|
|
* has to print whether or not it is registered yet, and printing must not depend on the gateway
|
|
* being up. Per-line tax comes from `resolveLineTax`, the same resolver that decided what was
|
|
* actually filed, so the paper and the filing cannot disagree.
|
|
*/
|
|
private buildMorDetails(
|
|
invoice: Invoice & { lines: InvoiceLine[] },
|
|
cfg: EimsConfig,
|
|
): InvoiceDocumentModel["mor"] {
|
|
const seller = buildEimsSeller(cfg);
|
|
const company = invoice.company;
|
|
const documentType = (invoice.eimsDocumentType as "INV" | "DEB" | "CRE" | undefined) ?? "INV";
|
|
// CREDIT until the money is in: the title states the sale's payment nature, not its status.
|
|
const isCash = Number(invoice.paidAmount) >= Number(invoice.totalAmount);
|
|
|
|
const TITLES: Record<string, { am: string; en: string }> = {
|
|
INV: isCash
|
|
? { am: "የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Cash sales invoice / VAT / Excise Tax" }
|
|
: { am: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Credit sales invoice / VAT / Excise Tax" },
|
|
CRE: { am: "የታክስ ክሬዲት ሰነድ", en: "Tax Credit Note" },
|
|
DEB: { am: "የታክስ ዴቢት ሰነድ", en: "Tax Debit Note" },
|
|
};
|
|
|
|
let total = 0;
|
|
let excise = 0;
|
|
let discount = 0;
|
|
let vatAmount = 0;
|
|
let vatTaxable = 0;
|
|
for (const line of invoice.lines) {
|
|
const tax = resolveLineTax(cfg, line.chargeType);
|
|
const lineTotal = Number(line.amount);
|
|
total += lineTotal;
|
|
excise += tax.exciseTaxValue;
|
|
discount += tax.discount;
|
|
if (tax.ratePercent > 0) {
|
|
vatTaxable += lineTotal;
|
|
vatAmount += (lineTotal * tax.ratePercent) / 100;
|
|
}
|
|
}
|
|
const totalIncludingTax = Number(invoice.totalAmount);
|
|
const rate = cfg.invoice.taxRatePercent ?? 0;
|
|
|
|
const title = TITLES[documentType] ?? TITLES.INV;
|
|
|
|
return {
|
|
titleAm: title.am,
|
|
titleEn: title.en,
|
|
saleType: cfg.invoice.transactionType,
|
|
irn: invoice.eimsIrn,
|
|
systemNumber: cfg.systemNumber || null,
|
|
referenceNumber: invoice.eimsDocumentNumber ?? null,
|
|
relatedDocumentIrn: invoice.relatedInvoice?.eimsIrn ?? null,
|
|
seller: {
|
|
name: cfg.invoice.sellerLegalName || seller.LegalName,
|
|
city: seller.City,
|
|
subCity: seller.SubCity,
|
|
woreda: seller.Wereda,
|
|
kebele: seller.Locality,
|
|
houseNo: seller.HouseNumber,
|
|
tin: seller.Tin,
|
|
vatNumber: seller.VatNumber,
|
|
},
|
|
buyer: {
|
|
name: company?.name ?? "N/A",
|
|
city: company?.zone ?? null,
|
|
subCity: company?.zone ?? null,
|
|
woreda: company?.woreda ?? null,
|
|
kebele: company?.kebele ?? null,
|
|
houseNo: company?.houseNo ?? null,
|
|
tin: company?.tin ?? null,
|
|
vatNumber: company?.vatNumber ?? null,
|
|
},
|
|
tax: {
|
|
total: round2(total),
|
|
discount: round2(discount),
|
|
taxableTotal: round2(vatTaxable),
|
|
excise: round2(excise),
|
|
vatTaxableAmount: round2(vatTaxable),
|
|
// An exempt seller still prints the row, labelled the way the Ministry's portal labels it.
|
|
vatLabel: rate > 0 ? `ተ.እ.ታ / VAT ${rate}%` : `${cfg.invoice.taxCode} ታክስ / ${cfg.invoice.taxCode} Tax rate (N/A%)`,
|
|
vatAmount: round2(vatAmount),
|
|
incomeWithholding: cfg.invoice.incomeWithholdValue ?? 0,
|
|
vatWithholding: cfg.invoice.transactionWithholdValue ?? 0,
|
|
totalIncludingTax: round2(totalIncludingTax),
|
|
amountInWords: amountInWords(totalIncludingTax),
|
|
},
|
|
payment: {
|
|
mode: isCash ? "CASH" : "CREDIT",
|
|
typeMethod: cfg.invoice.paymentTerm,
|
|
receiverName: company?.name ?? null,
|
|
},
|
|
// A memo is an amendment to a filed document; MoR's layout carries the sign-off that
|
|
// authorised it. Names come from the recorded reason until an approval chain exists.
|
|
approval:
|
|
documentType === "INV"
|
|
? null
|
|
: { requestedBy: invoice.eimsReason ?? null, checkedBy: null, approvedBy: null },
|
|
};
|
|
}
|
|
|
|
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
|
|
|
/** Resolve the customer's company id from their IAM user id (null if none). */
|
|
async resolveCompanyId(userId: string): Promise<string | null> {
|
|
try {
|
|
const { company } = await this.companies.getCompanyInfoByUserId(userId);
|
|
return company?.id ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every invoice billed to a company, newest first, with billing relations.
|
|
* Optionally narrow to a single source record (e.g. a booking's invoices) via
|
|
* `{ source, sourceId }`.
|
|
*/
|
|
findByCompany(
|
|
companyId: string,
|
|
filter: { source?: string; sourceId?: string } = {},
|
|
): Promise<Invoice[]> {
|
|
return this.invoices.findAll({
|
|
where: {
|
|
companyId,
|
|
...(filter.source ? { source: filter.source } : {}),
|
|
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
|
|
},
|
|
relations: { company: true, companyProfile: true },
|
|
order: { createdAt: "DESC" },
|
|
});
|
|
}
|
|
|
|
/** Invoices for a batch of source records (e.g. many last-mile legs), so a
|
|
* list can show which records already have an invoice without N+1 queries. */
|
|
findBySourceIds(source: string, sourceIds: string[]): Promise<Invoice[]> {
|
|
if (!sourceIds.length) return Promise.resolve([]);
|
|
return this.invoices.findAll({
|
|
where: { source, sourceId: In(sourceIds) },
|
|
order: { createdAt: "DESC" },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve a shipping-line company from the signed-in user (null for ordinary
|
|
* customers). Queried straight off the entity rather than through
|
|
* ShippingLineCompaniesService — that module already imports billing, so a
|
|
* service edge back would deepen the forwardRef cycle for one lookup.
|
|
*/
|
|
private async resolveShippingLineCompanyId(
|
|
userId: string,
|
|
): Promise<string | null> {
|
|
const line = await this.dataSource
|
|
.getRepository(ShippingLineCompany)
|
|
.findOne({ where: { userId } });
|
|
return line?.id ?? null;
|
|
}
|
|
|
|
/**
|
|
* Invoices for the signed-in portal user; empty when they have no company.
|
|
* A payer is either a customer company or a shipping line (enforced by the
|
|
* DB's single-payer check), so the two lookups cannot both match.
|
|
*/
|
|
async findForUser(
|
|
userId: string,
|
|
filter: { source?: string; sourceId?: string } = {},
|
|
): Promise<Invoice[]> {
|
|
const companyId = await this.resolveCompanyId(userId);
|
|
if (companyId) return this.findByCompany(companyId, filter);
|
|
|
|
const shippingLineCompanyId =
|
|
await this.resolveShippingLineCompanyId(userId);
|
|
if (!shippingLineCompanyId) return [];
|
|
return this.invoices.findAll({
|
|
where: {
|
|
shippingLineCompanyId,
|
|
...(filter.source ? { source: filter.source } : {}),
|
|
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
|
|
},
|
|
order: { createdAt: "DESC" },
|
|
});
|
|
}
|
|
|
|
/** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
|
async findByIdForUser(
|
|
id: string,
|
|
userId: string,
|
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
|
const invoice = await this.findById(id);
|
|
const ownedByCompany =
|
|
invoice.companyId != null &&
|
|
invoice.companyId === (await this.resolveCompanyId(userId));
|
|
const ownedByShippingLine =
|
|
!ownedByCompany &&
|
|
invoice.shippingLineCompanyId != null &&
|
|
invoice.shippingLineCompanyId ===
|
|
(await this.resolveShippingLineCompanyId(userId));
|
|
if (!ownedByCompany && !ownedByShippingLine) {
|
|
throw new NotFoundException(`Invoice ${id} not found`);
|
|
}
|
|
return invoice;
|
|
}
|
|
|
|
/**
|
|
* Initiate gateway payment for one of the customer's own invoices. Verifies
|
|
* ownership, then charges the invoice directly by ID (see {@link payInvoice}).
|
|
*/
|
|
async payInvoiceForUser(
|
|
id: string,
|
|
userId: string,
|
|
opts: PayInvoiceOptions = {},
|
|
): Promise<InitiateResponseDto> {
|
|
await this.findByIdForUser(id, userId);
|
|
return this.payInvoice(id, opts);
|
|
}
|
|
|
|
/**
|
|
* Submit the CAC Bank OTP for one of the customer's own invoices
|
|
* (ownership-checked). Settlement of the invoice happens inside the payment
|
|
* service when the OTP succeeds.
|
|
*/
|
|
async confirmInvoiceOtpForUser(
|
|
id: string,
|
|
userId: string,
|
|
otp: string,
|
|
): Promise<IntentStatusDto> {
|
|
await this.findByIdForUser(id, userId);
|
|
return this.confirmInvoiceOtp(id, otp);
|
|
}
|
|
|
|
/** OTP confirmation by invoice id — the intent is the one stamped at initiate. */
|
|
async confirmInvoiceOtp(
|
|
invoiceId: string,
|
|
otp: string,
|
|
): Promise<IntentStatusDto> {
|
|
const invoice = await this.dataSource
|
|
.getRepository(Invoice)
|
|
.findOne({ where: { id: invoiceId } });
|
|
if (!invoice?.paymentId) {
|
|
throw new NotFoundException("No payment to confirm for this invoice");
|
|
}
|
|
return this.payment.confirmOtp(invoice.paymentId, otp);
|
|
}
|
|
|
|
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
|
async documentForUser(
|
|
id: string,
|
|
userId: string,
|
|
): Promise<{ filename: string; buffer: Buffer }> {
|
|
await this.findByIdForUser(id, userId);
|
|
return this.document(id);
|
|
}
|
|
|
|
/** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */
|
|
async receiptForUser(
|
|
id: string,
|
|
userId: string,
|
|
): Promise<{ filename: string; buffer: Buffer }> {
|
|
await this.findByIdForUser(id, userId);
|
|
return this.receipt(id);
|
|
}
|
|
|
|
// ── Generation ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code`
|
|
* defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent
|
|
* daily sequence (different prefix hashes to a different advisory lock, see
|
|
* `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers.
|
|
*/
|
|
private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise<string> {
|
|
return nextDailyInvoiceNumber(mg, {
|
|
table: "freight.invoices",
|
|
code,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate an invoice for any source (booking, demurrage, manual, …).
|
|
*
|
|
* Persists the header plus its lines in one transaction and assigns the next
|
|
* sequential `invoice_number`. The total defaults to the sum of line amounts
|
|
* unless `totalAmount` is given. Issued invoices (default PENDING) stamp
|
|
* `issuedAt`; pass `status: DRAFT` to leave it unissued.
|
|
*
|
|
* Pass `manager` to enlist in a caller's transaction (e.g. when generating an
|
|
* invoice as part of a larger booking flow).
|
|
*/
|
|
async generateInvoice(
|
|
input: GenerateInvoiceInput,
|
|
manager?: EntityManager,
|
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
|
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
|
return manager ? run(manager) : this.dataSource.transaction(run);
|
|
}
|
|
|
|
/**
|
|
* Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed
|
|
* DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`,
|
|
* `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice`
|
|
* unchanged: it has no side effects (no events, no notifications, no payment records — every
|
|
* event in this service fires from `runTransition` on a *transition*, not on create), so a memo
|
|
* is just an ordinary invoice with three extra columns set.
|
|
*
|
|
* `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId`
|
|
* (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by
|
|
* `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the
|
|
* newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own
|
|
* `id` is never a value those lookups are ever queried with, so this isolates a memo from all
|
|
* of them regardless of its status — no `type`-based exclusion needed anywhere else.
|
|
*
|
|
* A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so
|
|
* leaving it payable would only add a phantom receivable that no payment flow will ever close.
|
|
* A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary
|
|
* invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is
|
|
* findable and collectible through the normal invoice list/detail/payment tooling, safe from
|
|
* the CBE/booking-linked lookups above for the `sourceId` reason just given.
|
|
*/
|
|
async issueMemo(
|
|
originalId: string,
|
|
input: IssueMemoInput,
|
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
|
const reason = input.reason?.trim();
|
|
if (!reason) {
|
|
throw new BadRequestException("A memo requires a reason.");
|
|
}
|
|
|
|
const original = await this.findById(originalId);
|
|
if (!original.eimsIrn) {
|
|
throw new BadRequestException({
|
|
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
|
message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`,
|
|
});
|
|
}
|
|
if (original.eimsDocumentType && original.eimsDocumentType !== "INV") {
|
|
throw new BadRequestException(
|
|
`Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`,
|
|
);
|
|
}
|
|
if (original.eimsStatus === EimsInvoiceStatus.Cancelled) {
|
|
throw new BadRequestException(
|
|
`Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`,
|
|
);
|
|
}
|
|
|
|
const sourceLines = input.lines?.length ? input.lines : original.lines;
|
|
const lines: InvoiceLineInput[] = sourceLines.map((l) => ({
|
|
chargeType: l.chargeType,
|
|
description: l.description,
|
|
quantity: Number(l.quantity),
|
|
unitRate: Number(l.unitRate),
|
|
amount: Number(l.amount),
|
|
currency: l.currency,
|
|
metadata: l.metadata ?? null,
|
|
}));
|
|
|
|
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.");
|
|
}
|
|
// Only a credit note is bounded by the original — it can only give back what was charged. A
|
|
// debit note is an additional charge, not a refund, so no such ceiling applies to it (do not
|
|
// assume the credit-note ceiling is correct for DEB).
|
|
if (input.type === "CRE" && total > Number(original.totalAmount)) {
|
|
throw new BadRequestException(
|
|
`Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`,
|
|
);
|
|
}
|
|
|
|
const code = input.type === "CRE" ? "CRE" : "DEB";
|
|
const settled = input.type === "CRE";
|
|
|
|
return this.dataSource.transaction(async (mg) => {
|
|
const memo = await this.createInvoice(
|
|
{
|
|
source: original.source as Freight.InvoiceSource,
|
|
sourceId: original.id,
|
|
type: input.type === "CRE" ? "credit_note" : "debit_note",
|
|
companyId: original.companyId,
|
|
companyProfileId: original.companyProfileId,
|
|
shippingLineCompanyId: original.shippingLineCompanyId,
|
|
lines,
|
|
currency: original.currency,
|
|
subtotalAmount: total,
|
|
taxAmount: 0,
|
|
totalAmount: total,
|
|
...(settled
|
|
? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() }
|
|
: {}),
|
|
},
|
|
mg,
|
|
code,
|
|
);
|
|
|
|
const patch: Record<string, unknown> = {
|
|
eimsDocumentType: input.type,
|
|
eimsReason: reason,
|
|
relatedInvoiceId: original.id,
|
|
...(settled
|
|
? {
|
|
paidAmount: memo.totalAmount,
|
|
balanceAmount: 0,
|
|
paidAt: new Date(),
|
|
}
|
|
: {}),
|
|
};
|
|
await mg.update(Invoice, memo.id, patch);
|
|
|
|
this.logger.log(
|
|
`Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`,
|
|
);
|
|
return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] };
|
|
});
|
|
}
|
|
|
|
private async createInvoice(
|
|
input: GenerateInvoiceInput,
|
|
mg: EntityManager,
|
|
code = "INV",
|
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
|
const currency = input.currency ?? "ETB";
|
|
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
|
const issued = status !== Freight.InvoiceStatus.Draft;
|
|
|
|
// Exactly one payer, checked here so a bad payload fails with a clear
|
|
// message instead of a raw `chk_invoices_single_payer` violation.
|
|
const billsCompany = Boolean(input.companyId);
|
|
const billsShippingLine = Boolean(input.shippingLineCompanyId);
|
|
if (billsCompany === billsShippingLine) {
|
|
throw new BadRequestException(
|
|
"An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.",
|
|
);
|
|
}
|
|
if (billsCompany && !input.companyProfileId) {
|
|
throw new BadRequestException(
|
|
"companyProfileId is required when billing a company.",
|
|
);
|
|
}
|
|
|
|
const lines = input.lines.map((l) => {
|
|
const quantity = l.quantity ?? 1;
|
|
const unitRate = l.unitRate ?? 0;
|
|
return {
|
|
chargeType: l.chargeType,
|
|
description: l.description,
|
|
quantity,
|
|
unitRate,
|
|
amount: l.amount ?? quantity * unitRate,
|
|
currency: l.currency ?? currency,
|
|
metadata: l.metadata ?? null,
|
|
};
|
|
});
|
|
|
|
const subtotalAmount =
|
|
input.subtotalAmount ??
|
|
lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
|
const taxAmount = input.taxAmount ?? 0;
|
|
const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
|
|
|
|
const dueAt =
|
|
input.dueAt ??
|
|
new Date(
|
|
Date.now() +
|
|
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
|
);
|
|
|
|
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
|
|
|
|
const invoice = await mg.save(
|
|
mg.create(Invoice, {
|
|
invoiceNumber,
|
|
source: input.source,
|
|
sourceId: input.sourceId,
|
|
type: input.type,
|
|
companyId: input.companyId ?? null,
|
|
companyProfileId: input.companyProfileId ?? null,
|
|
shippingLineCompanyId: input.shippingLineCompanyId ?? null,
|
|
subtotalAmount: round2(subtotalAmount),
|
|
taxAmount: round2(taxAmount),
|
|
totalAmount: round2(totalAmount),
|
|
paidAmount: 0,
|
|
balanceAmount: round2(totalAmount),
|
|
payments: [],
|
|
currency,
|
|
status,
|
|
issuedAt: issued ? new Date() : null,
|
|
dueAt,
|
|
}),
|
|
);
|
|
|
|
const savedLines = await Promise.all(
|
|
lines.map((l) =>
|
|
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
|
|
),
|
|
);
|
|
|
|
this.logger.log(
|
|
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
|
|
);
|
|
|
|
return { ...invoice, lines: savedLines };
|
|
}
|
|
|
|
// ── State transitions ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Run `fn` inside a transaction and only emit its returned domain event
|
|
* after commit. When the caller passes their own `manager`, they own commit
|
|
* timing — `fn`'s event fires inline as soon as it resolves (the outer
|
|
* transaction may still roll back afterwards; this is the caller's
|
|
* documented tradeoff). When no `manager` is given, this opens its own
|
|
* transaction and defers the emit until after that transaction commits, so
|
|
* listeners (e.g. booking advancement) can never observe an invoice change
|
|
* that then rolls back.
|
|
*/
|
|
private async runTransition<T>(
|
|
manager: EntityManager | undefined,
|
|
fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>,
|
|
): Promise<T> {
|
|
if (manager) {
|
|
const { result, emit } = await fn(manager);
|
|
emit?.();
|
|
return result;
|
|
}
|
|
let pending: (() => void) | undefined;
|
|
const result = await this.dataSource.transaction(async (mg) => {
|
|
const out = await fn(mg);
|
|
pending = out.emit;
|
|
return out.result;
|
|
});
|
|
pending?.();
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts,
|
|
* append the settlement to the `payments` ledger, link the gateway payment,
|
|
* then emit `${source}.invoice.paid`. Full-payment only — no partial
|
|
* settlement. No-op when the invoice is already paid. Pass `manager` to
|
|
* enlist in a caller's transaction; otherwise locks the row for update and
|
|
* emits only after commit (see {@link runTransition}).
|
|
*/
|
|
async markInvoiceAsPaid(
|
|
invoiceId: string,
|
|
paymentId: string | null = null,
|
|
manager?: EntityManager,
|
|
settlement: { providerTxnId?: string; paidAt?: Date } = {},
|
|
): Promise<Invoice | null> {
|
|
return this.runTransition(manager, async (mg) => {
|
|
const invoice = await mg.findOne(Invoice, {
|
|
where: { id: invoiceId },
|
|
lock: { mode: "pessimistic_write" },
|
|
});
|
|
if (!invoice) {
|
|
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
|
}
|
|
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
|
return { result: invoice };
|
|
}
|
|
|
|
const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date();
|
|
const settledAmount = round2(
|
|
Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0),
|
|
);
|
|
const entry: InvoicePayment = {
|
|
amount: settledAmount,
|
|
method: "GATEWAY",
|
|
reference: settlement.providerTxnId ?? paymentId ?? null,
|
|
paidAt: paidAt.toISOString(),
|
|
metadata: null,
|
|
};
|
|
const payments = [...(invoice.payments ?? []), entry];
|
|
|
|
const patch = {
|
|
status: Freight.InvoiceStatus.Paid,
|
|
paymentId,
|
|
paidAt,
|
|
paidAmount: invoice.totalAmount,
|
|
balanceAmount: 0,
|
|
payments,
|
|
};
|
|
await mg.update(Invoice, { id: invoiceId }, patch as never);
|
|
|
|
const updated = { ...invoice, ...patch } as Invoice;
|
|
return {
|
|
result: updated,
|
|
emit: () => this.emitInvoiceEvent("paid", updated),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Record a (possibly partial) settlement against an invoice and sync its
|
|
* status. Appends to the `payments` ledger, recomputes `paidAmount` /
|
|
* `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the
|
|
* balance reaches zero — PAID, stamping `paidAt` and emitting
|
|
* `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash
|
|
* at the warehouse counter); gateway settlement goes through
|
|
* {@link markInvoiceAsPaid}.
|
|
*
|
|
* Throws when the invoice is missing, cancelled, refunded, already fully
|
|
* paid, `amount` is not positive, or `amount` exceeds the outstanding
|
|
* balance. Pass `manager` to enlist in a caller's transaction; otherwise
|
|
* locks the row for update and emits only after commit (see
|
|
* {@link runTransition}).
|
|
*/
|
|
async recordPayment(
|
|
invoiceId: string,
|
|
input: RecordPaymentInput,
|
|
manager?: EntityManager,
|
|
): Promise<Invoice> {
|
|
if (!(input.amount > 0)) {
|
|
throw new BadRequestException(
|
|
"Payment amount must be greater than zero.",
|
|
);
|
|
}
|
|
|
|
return this.runTransition(manager, async (mg) => {
|
|
const invoice = await mg.findOne(Invoice, {
|
|
where: { id: invoiceId },
|
|
lock: { mode: "pessimistic_write" },
|
|
});
|
|
if (!invoice) {
|
|
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
|
}
|
|
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
|
|
throw new BadRequestException("Cannot pay a cancelled invoice.");
|
|
}
|
|
if (invoice.status === Freight.InvoiceStatus.Refunded) {
|
|
throw new BadRequestException("Cannot pay a refunded invoice.");
|
|
}
|
|
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
|
throw new BadRequestException("Invoice is already fully paid.");
|
|
}
|
|
// M27: a Draft invoice is not yet issued and an Expired invoice's pay
|
|
// window has closed — neither is payable. Without these guards a payment
|
|
// could settle an unissued draft or a lapsed invoice.
|
|
if (invoice.status === Freight.InvoiceStatus.Draft) {
|
|
throw new BadRequestException(
|
|
"Cannot pay a draft invoice — it must be issued first.",
|
|
);
|
|
}
|
|
if (invoice.status === Freight.InvoiceStatus.Expired) {
|
|
throw new BadRequestException(
|
|
"Cannot pay an expired invoice — its payment window has closed.",
|
|
);
|
|
}
|
|
if (round2(input.amount) > Number(invoice.balanceAmount)) {
|
|
throw new BadRequestException(
|
|
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
|
|
);
|
|
}
|
|
|
|
const at = input.paidAt ?? new Date();
|
|
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
|
|
invoice.totalAmount,
|
|
invoice.paidAmount,
|
|
input.amount,
|
|
);
|
|
const status = fullyPaid
|
|
? Freight.InvoiceStatus.Paid
|
|
: Freight.InvoiceStatus.PartiallyPaid;
|
|
|
|
const entry: InvoicePayment = {
|
|
amount: round2(input.amount),
|
|
method: input.method ?? null,
|
|
reference: input.reference ?? null,
|
|
paidAt: at.toISOString(),
|
|
metadata: input.metadata ?? null,
|
|
};
|
|
const payments = [...(invoice.payments ?? []), entry];
|
|
|
|
const patch = {
|
|
paidAmount,
|
|
balanceAmount,
|
|
status,
|
|
payments,
|
|
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
|
|
};
|
|
await mg.update(Invoice, { id: invoice.id }, patch as never);
|
|
|
|
const updated = { ...invoice, ...patch } as Invoice;
|
|
return {
|
|
result: updated,
|
|
emit: fullyPaid
|
|
? () => this.emitInvoiceEvent("paid", updated)
|
|
: undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
|
* No-op when already refunded. Throws when the invoice has no recorded
|
|
* payment (nothing to refund).
|
|
*/
|
|
async markInvoiceAsRefunded(
|
|
invoiceId: string,
|
|
manager?: EntityManager,
|
|
): Promise<Invoice | null> {
|
|
return this.transition(
|
|
invoiceId,
|
|
Freight.InvoiceStatus.Refunded,
|
|
"refunded",
|
|
{},
|
|
manager,
|
|
(invoice) => {
|
|
if (!(Number(invoice.paidAmount) > 0)) {
|
|
throw new BadRequestException(
|
|
"Cannot refund an invoice with no recorded payment.",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
|
|
* No-op when already cancelled. Throws when the invoice has payments
|
|
* recorded against it (refund it instead).
|
|
*/
|
|
async cancelInvoice(
|
|
invoiceId: string,
|
|
manager?: EntityManager,
|
|
): Promise<Invoice | null> {
|
|
return this.transition(
|
|
invoiceId,
|
|
Freight.InvoiceStatus.Cancelled,
|
|
"cancelled",
|
|
{},
|
|
manager,
|
|
(invoice) => {
|
|
if (Number(invoice.paidAmount) > 0) {
|
|
throw new BadRequestException(
|
|
"Cannot cancel an invoice that has payments recorded against it.",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Load the invoice, apply the new status (+ extra columns), then emit
|
|
* `${source}.invoice.<event>`. No-op (returns the invoice, skipping `guard`)
|
|
* when it is already in the target status. Throws when the invoice does not
|
|
* exist or `guard` rejects the current state. Pass `manager` to enlist in a
|
|
* caller's transaction; otherwise locks the row for update and emits only
|
|
* after commit (see {@link runTransition}).
|
|
*/
|
|
private async transition(
|
|
invoiceId: string,
|
|
status: Freight.InvoiceStatus,
|
|
event: string,
|
|
extra: { paymentId?: string },
|
|
manager?: EntityManager,
|
|
guard?: (invoice: Invoice) => void,
|
|
): Promise<Invoice | null> {
|
|
return this.runTransition(manager, async (mg) => {
|
|
const invoice = await mg.findOne(Invoice, {
|
|
where: { id: invoiceId },
|
|
lock: { mode: "pessimistic_write" },
|
|
});
|
|
if (!invoice) {
|
|
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
|
}
|
|
if (invoice.status === status) return { result: invoice };
|
|
guard?.(invoice);
|
|
|
|
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
|
|
|
// Every invoice status move in the app funnels through here — money
|
|
// changing state is the single most-asked question in support.
|
|
logCtx(
|
|
{
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
source: invoice.source,
|
|
sourceId: invoice.sourceId,
|
|
from: invoice.status,
|
|
to: status,
|
|
event,
|
|
amount: Number(invoice.totalAmount),
|
|
currency: invoice.currency,
|
|
paymentId: extra.paymentId ?? invoice.paymentId ?? undefined,
|
|
},
|
|
{ path: "invoiceTransitions", mode: "push" },
|
|
);
|
|
|
|
const updated = { ...invoice, ...extra, status } as Invoice;
|
|
return {
|
|
result: updated,
|
|
emit: () => this.emitInvoiceEvent(event, updated),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
|
|
private emitInvoiceEvent(event: string, invoice: Invoice): void {
|
|
const payload: InvoiceEventPayload = {
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
source: invoice.source as Freight.InvoiceSource,
|
|
sourceId: invoice.sourceId,
|
|
type: invoice.type,
|
|
companyId: invoice.companyId,
|
|
companyProfileId: invoice.companyProfileId,
|
|
shippingLineCompanyId: invoice.shippingLineCompanyId ?? null,
|
|
totalAmount: invoice.totalAmount,
|
|
currency: invoice.currency,
|
|
status: invoice.status,
|
|
paymentId: invoice.paymentId ?? null,
|
|
};
|
|
this.events
|
|
.emitAsync(`${invoice.source}.invoice.${event}`, payload)
|
|
.catch((err) =>
|
|
this.logger.error(
|
|
`Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
|
|
|
/**
|
|
* The invoice a source record already has open, or null if it needs a new
|
|
* one. This is the idempotency check every `ensureInvoiceFor*` (booking,
|
|
* first-mile, last-mile) runs before generating — it must see DRAFT
|
|
* invoices too, not just issued ones, otherwise a source that already has
|
|
* an unissued draft gets a second, duplicate invoice minted alongside it
|
|
* instead of that draft being reused and then issued.
|
|
*
|
|
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
|
* a booking's up-front vs final charge); omit it to settle whichever single
|
|
* invoice is currently open. Returns the most recent matching draft-or-open
|
|
* (unpaid, non-cancelled) invoice.
|
|
*/
|
|
findPayable(
|
|
source: Freight.InvoiceSource,
|
|
sourceId: string,
|
|
type?: string,
|
|
): Promise<Invoice | null> {
|
|
return this.dataSource.getRepository(Invoice).findOne({
|
|
where: {
|
|
source,
|
|
sourceId,
|
|
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
|
...(type ? { type } : {}),
|
|
},
|
|
order: { issuedAt: "DESC" },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
|
* a booking's up-front vs final charge); omit it to settle whichever single
|
|
* invoice is currently open. Returns the most recent matching open (unpaid,
|
|
* non-cancelled) invoice.
|
|
*/
|
|
findInvoice(
|
|
source: Freight.InvoiceSource,
|
|
sourceId: string,
|
|
type?: string,
|
|
): Promise<Invoice | null> {
|
|
return this.dataSource.getRepository(Invoice).findOne({
|
|
where: {
|
|
source,
|
|
sourceId,
|
|
...(type ? { type } : {}),
|
|
},
|
|
order: { issuedAt: "DESC" },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Expire a source's currently-open invoice (its pay window closed before
|
|
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
|
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
|
* `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to
|
|
* retire (already paid/cancelled/expired).
|
|
*
|
|
* DRAFT invoices are matched too, even though they were never issued: this is
|
|
* also the "retire the invoice this source no longer needs" path (a cancelled
|
|
* booking, or a full-amount invoice superseded by a partial-offer one). Skipping
|
|
* drafts would leave the stale one behind for `findPayable` to hand back — the
|
|
* superseding invoice would then never be minted, and a cancelled booking would
|
|
* keep a draft that a later `issuePayable` could still make payable.
|
|
*
|
|
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
|
* the batch engine) to enlist in its DB transaction.
|
|
*/
|
|
async expirePayable(
|
|
source: Freight.InvoiceSource,
|
|
sourceId: string,
|
|
type?: string,
|
|
manager?: EntityManager,
|
|
): Promise<Invoice | null> {
|
|
// Lookup can use the default manager (no lock). But the pessimistic-lock write
|
|
// inside `transition` NEEDS an open transaction: pass the caller's `manager`
|
|
// through untouched (undefined when there is no caller txn) so `runTransition`
|
|
// opens its own. Passing `this.dataSource.manager` here made `runTransition`
|
|
// treat it as an already-open transaction and skip wrapping — the lock then
|
|
// threw `An open transaction is required for pessimistic lock`, aborting the
|
|
// whole settle pass (the "reservations settle/reserve one at a time" symptom).
|
|
const mg = manager ?? this.dataSource.manager;
|
|
const invoice = await mg.findOne(Invoice, {
|
|
where: {
|
|
source,
|
|
sourceId,
|
|
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
|
...(type ? { type } : {}),
|
|
},
|
|
order: { issuedAt: "DESC" },
|
|
});
|
|
if (!invoice) return null;
|
|
|
|
// Reconcile-before-expire, caller-proof: an invoice with a payment intent may
|
|
// have settled at the gateway without the webhook landing yet. `paid` — leave
|
|
// it open, the (re-emitted) payment.succeeded settles it. `unverifiable` —
|
|
// never expire on unknown; the caller's next sweep retries. Invoices with no
|
|
// intent (`paymentId` null) were never payable at a gateway and expire directly.
|
|
if (invoice.paymentId) {
|
|
const { paid, unverifiable } = await this.reconcilePayable(
|
|
invoice.sourceId,
|
|
);
|
|
if (paid || unverifiable) {
|
|
this.logger.warn(
|
|
`expirePayable skipped for invoice ${invoice.invoiceNumber} (${invoice.id}) — ` +
|
|
(paid
|
|
? "gateway reconcile found a settled payment"
|
|
: "settlement unverifiable at the gateway"),
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return this.transition(
|
|
invoice.id,
|
|
Freight.InvoiceStatus.Expired,
|
|
"expired",
|
|
{},
|
|
manager,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Issue a source's invoice and stamp its real pay-window deadline — the single
|
|
* transition that makes a source payable.
|
|
*
|
|
* A source's invoice is minted DRAFT, before any pay window exists (e.g. a
|
|
* booking invoice is generated at creation / operation-accept, long before the
|
|
* batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`,
|
|
* so such an invoice is not settleable and the portal renders no pay button.
|
|
* The domain calls this at the moment the pay window actually opens (booking →
|
|
* `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues
|
|
* the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`.
|
|
*
|
|
* Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so
|
|
* a re-reserve never re-issues. No-op (returns null) when the source has no
|
|
* draft-or-open invoice (already paid/cancelled/expired).
|
|
*/
|
|
async issuePayable(
|
|
source: Freight.InvoiceSource,
|
|
sourceId: string,
|
|
dueAt: Date,
|
|
type?: string,
|
|
manager?: EntityManager,
|
|
): Promise<Invoice | null> {
|
|
const mg = manager ?? this.dataSource.manager;
|
|
const invoice = await mg.findOne(Invoice, {
|
|
where: {
|
|
source,
|
|
sourceId,
|
|
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
|
...(type ? { type } : {}),
|
|
},
|
|
order: { issuedAt: "DESC" },
|
|
});
|
|
if (!invoice) return null;
|
|
|
|
const issuing = invoice.status === Freight.InvoiceStatus.Draft;
|
|
const patch = {
|
|
dueAt,
|
|
...(issuing
|
|
? {
|
|
status: Freight.InvoiceStatus.Pending,
|
|
issuedAt: invoice.issuedAt ?? new Date(),
|
|
}
|
|
: {}),
|
|
};
|
|
await mg.update(Invoice, { id: invoice.id }, patch);
|
|
|
|
if (issuing) {
|
|
this.logger.log(
|
|
`Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`,
|
|
);
|
|
}
|
|
return { ...invoice, ...patch } as Invoice;
|
|
}
|
|
|
|
/**
|
|
* Force an invoice to `status`, including issuing a still-DRAFT invoice
|
|
* (stamping `issuedAt`) — unlike the other transitions here, this is a
|
|
* blunt admin/workflow override, not a settlement. No-op when the invoice
|
|
* is missing or already terminal (paid/cancelled/refunded/expired).
|
|
*/
|
|
async updateStatus(
|
|
invoiceId: string,
|
|
status: Freight.InvoiceStatus,
|
|
manager?: EntityManager,
|
|
): Promise<void> {
|
|
// M27: this is the blunt "issue a draft" override — it stamps `issuedAt` but
|
|
// does NOT touch paidAmount/balanceAmount. Its only legitimate use is the
|
|
// Draft → Pending/Issued issue transition. It must NEVER mark an invoice
|
|
// Paid/Refunded/Cancelled/Expired (or PartiallyPaid/Overdue): those carry
|
|
// balance implications and must go through the dedicated settlement methods
|
|
// (recordPayment / markInvoiceAsRefunded / cancelInvoice / expirePayable).
|
|
if (
|
|
status !== Freight.InvoiceStatus.Pending &&
|
|
status !== Freight.InvoiceStatus.Issued
|
|
) {
|
|
throw new BadRequestException(
|
|
`updateStatus only issues an invoice (→ PENDING/ISSUED); use the dedicated settlement methods to set ${status}.`,
|
|
);
|
|
}
|
|
const mg = manager ?? this.dataSource.manager;
|
|
const invoice = await mg.findOne(Invoice, {
|
|
where: {
|
|
id: invoiceId,
|
|
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
|
},
|
|
});
|
|
if (!invoice) return;
|
|
await mg.update(
|
|
Invoice,
|
|
{ id: invoice.id },
|
|
{ status, issuedAt: invoice.issuedAt ?? new Date() },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Success-redirect ack (see PaymentService.acknowledgeSuccessRedirect): move
|
|
* the invoice linked to a gateway intent to PAYMENT_PROCESSING. Only from
|
|
* ISSUED/PENDING — never overwrites a settlement (PAID/PARTIALLY_PAID) and
|
|
* is idempotent. Balance untouched: this is a display state, not a
|
|
* settlement; settleByPaymentId still performs the real transition.
|
|
*/
|
|
async markInvoicePaymentProcessing(paymentId: string): Promise<void> {
|
|
const repo = this.dataSource.getRepository(Invoice);
|
|
const invoices = await repo.findBy({
|
|
paymentId,
|
|
status: In([Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending]),
|
|
});
|
|
for (const invoice of invoices) {
|
|
await repo.update(
|
|
{ id: invoice.id, status: invoice.status },
|
|
{ status: Freight.InvoiceStatus.PaymentProcessing },
|
|
);
|
|
this.emitInvoiceEvent("payment-processing", {
|
|
...invoice,
|
|
status: Freight.InvoiceStatus.PaymentProcessing,
|
|
} as Invoice);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Counterpart of {@link markInvoicePaymentProcessing} for a failed intent:
|
|
* PAYMENT_PROCESSING → PENDING so the invoice reads payable again for a
|
|
* retry. No-op from any other status.
|
|
*/
|
|
async revertInvoicePaymentProcessing(paymentId: string): Promise<void> {
|
|
const repo = this.dataSource.getRepository(Invoice);
|
|
const invoices = await repo.findBy({
|
|
paymentId,
|
|
status: Freight.InvoiceStatus.PaymentProcessing,
|
|
});
|
|
for (const invoice of invoices) {
|
|
await repo.update(
|
|
{ id: invoice.id, status: Freight.InvoiceStatus.PaymentProcessing },
|
|
{ status: Freight.InvoiceStatus.Pending },
|
|
);
|
|
this.emitInvoiceEvent("payment-processing-reverted", {
|
|
...invoice,
|
|
status: Freight.InvoiceStatus.Pending,
|
|
} as Invoice);
|
|
}
|
|
}
|
|
|
|
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
|
|
|
/**
|
|
* Charge an invoice through the payment gateway. Billing is the single place
|
|
* that turns "what is owed" (the invoice) into a payment intent — the domain
|
|
* never talks to the payment service directly. Resolves the invoice by ID,
|
|
* opens an intent for `invoice.balanceAmount` (so partial payments are honored),
|
|
* records the intent id on the invoice (the settlement correlation key), and
|
|
* returns the client action.
|
|
*
|
|
* When the provider settles synchronously, the invoice is settled inline here —
|
|
* after the intent id is stored — so the `payment.succeeded` correlation can
|
|
* never fire before the link exists. Throws when the invoice is not found or
|
|
* not in an open/payable status.
|
|
*/
|
|
/**
|
|
* Settlement check before expiring a payable order (reconcile-before-expire):
|
|
* live-queries the gateway for any settled intent on the source order. Kept
|
|
* on billing so the domain never talks to the payment service directly.
|
|
*/
|
|
reconcilePayable(
|
|
sourceId: string,
|
|
): Promise<{ paid: boolean; unverifiable: boolean }> {
|
|
return this.payment.reconcileShipment(sourceId);
|
|
}
|
|
|
|
async payInvoice(
|
|
invoiceId: string,
|
|
opts: {
|
|
method?: string;
|
|
platform?: "web" | "mobile";
|
|
payerAccount?: string;
|
|
returnUrl?: string;
|
|
failureUrl?: string;
|
|
} = {},
|
|
): Promise<InitiateResponseDto> {
|
|
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
|
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
|
relations: { company: true },
|
|
});
|
|
if (!invoice) {
|
|
throw new NotFoundException(
|
|
`Invoice ${invoiceId} not found or not in a payable status`,
|
|
);
|
|
}
|
|
|
|
// A booking's PREPAID invoice is only payable inside its pay window —
|
|
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
|
|
// Blocking INITIATION here is what makes the deadline real: a payment
|
|
// STARTED before this gate but settling late is still honored by the
|
|
// expire-time gateway reconcile. Other invoice types keep dueAt display-only.
|
|
if (
|
|
invoice.source === Freight.InvoiceSource.Booking &&
|
|
invoice.type === "PREPAID" &&
|
|
invoice.dueAt &&
|
|
invoice.dueAt.getTime() <= Date.now()
|
|
) {
|
|
throw new BadRequestException(
|
|
"The payment window for this booking has closed — the reserved wagons " +
|
|
"were released. Please book again.",
|
|
);
|
|
}
|
|
|
|
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
|
if (!(amountDue > 0)) {
|
|
throw new BadRequestException("Invoice has no outstanding balance.");
|
|
}
|
|
|
|
logCtx(
|
|
{
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
source: invoice.source,
|
|
sourceId: invoice.sourceId,
|
|
companyId: invoice.companyId,
|
|
amountDue,
|
|
currency: invoice.currency,
|
|
method: opts.method ?? "TELEBIRR",
|
|
platform: opts.platform,
|
|
},
|
|
{ path: "payment.payInvoice" },
|
|
);
|
|
|
|
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
|
|
// required up front (the payment service rejects it otherwise, as a 502 here).
|
|
if (
|
|
(opts.method ?? "").toUpperCase() === "CAC_BANK" &&
|
|
!opts.payerAccount?.trim()
|
|
) {
|
|
throw new BadRequestException(
|
|
"payerAccount (mobile number) is required for CAC Bank",
|
|
);
|
|
}
|
|
|
|
const result = await this.payment.initiate({
|
|
referenceId: invoice.sourceId,
|
|
source: invoice.source,
|
|
// Freight payments settle under the generic SHIPMENT reference — how the
|
|
// payment service attributes them to the freight API. The payment ↔ invoice
|
|
// link is the intent id (`paymentId`); per-source post-payment reactions live
|
|
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
|
// service branches on a domain-specific reference type.
|
|
referenceType: PaymentReferenceType.SHIPMENT,
|
|
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
|
// Exact balance, cents included. CBE bills this verbatim and /cbe/payment
|
|
// matches the debited amount to the cent (amountsMatchToTheCent), so any
|
|
// rounding here would overcharge the payer and leave the invoice balance
|
|
// non-zero. billQuery quotes the same unrounded value.
|
|
amountMinor: round2(Number(invoice.balanceAmount)),
|
|
currency: invoice.currency,
|
|
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
|
method: opts.method ?? "TELEBIRR",
|
|
platform: opts.platform,
|
|
payerAccount: opts.payerAccount,
|
|
// CBE_BILL: payer identity + the invoice's own due date as the bill expiry
|
|
// (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4).
|
|
payerName: invoice.company?.name,
|
|
expiresAt: invoice.dueAt?.toISOString(),
|
|
returnUrl: opts.returnUrl,
|
|
failureUrl: opts.failureUrl,
|
|
});
|
|
//
|
|
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
|
await this.dataSource
|
|
.getRepository(Invoice)
|
|
.update({ id: invoice.id }, { paymentId: result.intentId });
|
|
|
|
// CBE_BILL: the bill reference IS the booking's PNR — the number the customer pays against
|
|
// at any CBE channel. Persist it on the booking so it survives the initiate response and
|
|
// shows on the booking/contract everywhere. The payment service reissues the same reference
|
|
// while the bill stays open, so re-initiating overwrites with an identical value.
|
|
const billReference = result.response.clientAction?.billReference;
|
|
if (billReference && invoice.source === Freight.InvoiceSource.Booking) {
|
|
await this.dataSource
|
|
.getRepository(Booking)
|
|
.update({ id: invoice.sourceId }, { pnrCode: billReference });
|
|
}
|
|
// 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
|
|
) {
|
|
await this.dataSource
|
|
.getRepository(AdditionalCharge)
|
|
.update({ id: invoice.sourceId }, { paymentReference: billReference });
|
|
}
|
|
|
|
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
|
// billing must not simulate it. Kept for local demos only.
|
|
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
|
// code — so the demo shortcut must never fire for it. Same for CBE_BILL: its
|
|
// bill must stay open until CBE actually settles it via /cbe/payment.
|
|
// if (
|
|
// !result.immediateSuccess &&
|
|
// result.response.clientAction?.type !== "COLLECT_OTP" &&
|
|
// opts.method !== "CBE_BILL"
|
|
// ) {
|
|
// await this.payment.handlePaymentEvent({
|
|
// eventType: "payment.succeeded",
|
|
// eventId: `demo-${result.intentId}`,
|
|
// referenceId: invoice.sourceId,
|
|
// intentId: result.intentId,
|
|
// providerTxnId: result.providerTxnId,
|
|
// paidAt: (result.paidAt ?? new Date()).toISOString(),
|
|
// });
|
|
// }
|
|
|
|
if (result.immediateSuccess) {
|
|
await this.settleByPaymentId(
|
|
result.intentId,
|
|
result.providerTxnId,
|
|
result.paidAt,
|
|
);
|
|
}
|
|
|
|
return result.response;
|
|
}
|
|
|
|
/**
|
|
* Settle the open invoice linked to a gateway intent id, if any. Called by the
|
|
* payment service when an intent succeeds: finds the invoice linked by
|
|
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
|
|
* to advance on. Idempotent — no-op when no open invoice is linked (already
|
|
* settled, or settled inline by {@link payInvoice}).
|
|
*
|
|
* EXPIRED is settleable HERE and only here: this is the gateway path, so the
|
|
* money is already captured and we are recording a fait accompli. A success can
|
|
* land after the pay window plus its drain tail (relay backlog, payment-api
|
|
* restart, a CBE bill paid at a counter) — matching only `OPEN_STATUSES` used to
|
|
* drop it silently, leaving a debited customer with an EXPIRED invoice and no
|
|
* alert. The manual/offline path ({@link recordPayment}) keeps its EXPIRED guard:
|
|
* a teller must not accept cash against a lapsed invoice.
|
|
*
|
|
* The status is checked on the RESOLVED invoice, never inside the lookup.
|
|
* `paymentId` is freight's local intent projection, and `upsertIntent` keeps ONE
|
|
* row per booking reference across every pay attempt — so a booking that was
|
|
* re-invoiced after a lapsed attempt has SEVERAL invoices carrying the same
|
|
* `paymentId`. Filtering by status inside the query would let a late capture from
|
|
* attempt 1 skip past the already-PAID attempt-2 invoice and settle the older
|
|
* EXPIRED one, marking two invoices paid off a single capture. Resolving the
|
|
* newest invoice first and then asking whether IT is settleable makes the answer
|
|
* "this booking's money is already recorded" instead. CANCELLED/REFUNDED are
|
|
* refund cases, not settlements, and are logged rather than settled.
|
|
*/
|
|
async settleByPaymentId(
|
|
paymentId: string,
|
|
providerTxnId?: string,
|
|
paidAt?: Date,
|
|
): Promise<Invoice | null> {
|
|
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
|
where: { paymentId },
|
|
// NULLS LAST: a DRAFT invoice has no issuedAt and Postgres sorts NULLs
|
|
// first on DESC, which would hand back an unissued invoice.
|
|
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
|
|
});
|
|
if (!invoice) {
|
|
logCtx(
|
|
{ paymentId, outcome: "no-invoice-for-payment" },
|
|
{ path: "payment.settleInvoice" },
|
|
);
|
|
return null;
|
|
}
|
|
|
|
logCtx(
|
|
{
|
|
paymentId,
|
|
providerTxnId,
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
invoiceStatus: invoice.status,
|
|
},
|
|
{ path: "payment.settleInvoice" },
|
|
);
|
|
|
|
const settleable: Freight.InvoiceStatus[] = [
|
|
...OPEN_STATUSES,
|
|
Freight.InvoiceStatus.Expired,
|
|
];
|
|
if (!settleable.includes(invoice.status)) {
|
|
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
|
|
// inline by payInvoice). Anything else means money was captured with
|
|
// nowhere to land — that needs a person, so say so loudly.
|
|
logCtx(
|
|
invoice.status === Freight.InvoiceStatus.Paid
|
|
? "already-paid"
|
|
: "captured-with-nowhere-to-land",
|
|
{ path: "payment.settleInvoice.outcome", mode: "set" },
|
|
);
|
|
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
|
this.logger.error(
|
|
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
|
|
`(${invoice.id}) is ${invoice.status} — nothing settled. The capture ` +
|
|
`needs a refund or a manual settlement.`,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
|
|
providerTxnId,
|
|
paidAt,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for
|
|
* the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId,
|
|
* i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting.
|
|
*/
|
|
async billQuery(referenceId: string): Promise<{
|
|
stillPayable: boolean;
|
|
payerName?: string | null;
|
|
currentAmountMinor?: number | null;
|
|
currency?: string | null;
|
|
reason?: string | null;
|
|
paymentReason?: string | null;
|
|
}> {
|
|
const repo = this.dataSource.getRepository(Invoice);
|
|
const open = await repo.findOne({
|
|
where: { sourceId: referenceId, status: In(OPEN_STATUSES) },
|
|
relations: { company: true },
|
|
order: { issuedAt: "DESC" },
|
|
});
|
|
|
|
if (open) {
|
|
// Unrounded, matching payInvoice — the amount CBE quotes at the counter has
|
|
// to be the amount the intent was opened for, to the cent, or /cbe/payment
|
|
// sees a mismatch.
|
|
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
|
|
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
|
return {
|
|
stillPayable: balance > 0 && !expired,
|
|
payerName: open.company?.name ?? null,
|
|
currentAmountMinor: balance,
|
|
currency: open.currency,
|
|
// CBE shows this beside the amount on the confirmation screen — the invoice number
|
|
// the payer is holding, not our internal reference.
|
|
paymentReason: `Freight invoice ${open.invoiceNumber}`,
|
|
// Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not
|
|
// expired, and that is what the payer at the CBE counter must be told.
|
|
reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID",
|
|
};
|
|
}
|
|
|
|
const latest = await repo.findOne({
|
|
where: { sourceId: referenceId },
|
|
relations: { company: true },
|
|
order: { createdAt: "DESC" },
|
|
});
|
|
// A bill reference whose invoice no longer exists at all — a data problem, not a
|
|
// cancellation the payer did anything to cause.
|
|
if (!latest) {
|
|
return {
|
|
stillPayable: false,
|
|
payerName: null,
|
|
currentAmountMinor: null,
|
|
currency: null,
|
|
reason: "NOT_FOUND",
|
|
};
|
|
}
|
|
return {
|
|
stillPayable: false,
|
|
payerName: latest.company?.name ?? null,
|
|
currentAmountMinor: round2(Number(latest.totalAmount)),
|
|
currency: latest.currency,
|
|
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
|
reason: closedInvoiceReason(latest.status),
|
|
};
|
|
}
|
|
}
|