feat(freight): GL rebook of cancelled wagons, seal+train required on completion, train/voyage in SMS

This commit is contained in:
marshal
2026-09-02 21:23:24 +00:00
parent 3e274cc2a2
commit 6efa9dab93
13 changed files with 713 additions and 308 deletions

View File

@@ -14,6 +14,8 @@ import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentModule } from "../payment/payment.module";
import { CompaniesModule } from "../companies/companies.module";
import { FilesModule } from "../files/files.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
@Module({
imports: [
@@ -24,6 +26,10 @@ import { FilesModule } from "../files/files.module";
DocumentsModule,
UserTradeAccessModule,
FilesModule,
// Customer notice when Finance confirms a manual payment. The inbox module
// reaches this one back through CompaniesModule, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
],
controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],

View File

@@ -83,6 +83,8 @@ describe("BillingService.generateInvoice", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
});
@@ -166,6 +168,8 @@ describe("BillingService.issueMemo", () => {
{} as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, manager, savedLines };
}
@@ -301,6 +305,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -357,6 +363,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -403,6 +411,8 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, mg, events };
}
@@ -517,6 +527,8 @@ describe("BillingService.recordPayment", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, mg, events };
}
@@ -635,6 +647,8 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, defaultManager, txManager, transaction };
};
@@ -709,6 +723,8 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, manager };
};
@@ -801,6 +817,8 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, repo };
};
@@ -885,6 +903,8 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, repo };
};
@@ -955,6 +975,8 @@ describe("BillingService.document", () => {
: undefined,
} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, render, renderThermal };
};
@@ -1047,6 +1069,8 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
function makeService(invoiceType: string) {
const invoice = {
id: "inv-1",
invoiceNumber: "INV-001",
companyId: "company-1",
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: invoiceType,
@@ -1057,9 +1081,16 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
const recordPayment = jest.fn().mockResolvedValue(invoice);
const dataSource = {
getRepository: () => ({
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
findOne: async () => ({
id: "booking-1",
reference: "BK-001",
paymentDeadline: PAST,
}),
}),
query: async () => [{ phone: "+251900000000", email: "c@x.com" }],
};
const directSend = jest.fn().mockResolvedValue(undefined);
const notify = jest.fn().mockResolvedValue(undefined);
const service = new BillingService(
dataSource as never,
{ findById: async () => invoice } as never,
@@ -1071,10 +1102,12 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true } as never,
{ directSend } as never,
{ notify } as never,
);
(service as unknown as { recordPayment: unknown }).recordPayment =
recordPayment;
return { service, recordPayment };
return { service, recordPayment, directSend, notify };
}
const slip = { originalname: "slip.pdf" } as never;
@@ -1097,6 +1130,42 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
);
});
it("notifies the customer (inbox + SMS + email) once the payment is confirmed", async () => {
const { service, notify, directSend } = makeService(
WAGON_CANCEL_FEE_INVOICE_TYPE,
);
await service.confirmOfflinePayment("inv-1", slip, {});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({
recipients: { companyId: "company-1" },
type: "PAYMENT_RECEIVED",
link: "/billing/inv-1",
body: expect.stringMatching(/500 ETB .*INV-001 \(booking BK-001\)/),
}),
);
expect(directSend).toHaveBeenCalledWith(
"sms",
"+251900000000",
expect.stringContaining("INV-001"),
);
expect(directSend).toHaveBeenCalledWith(
"email",
"c@x.com",
expect.stringContaining("INV-001"),
);
});
it("still settles when the customer notice fails", async () => {
const { service, notify, recordPayment } = makeService(
WAGON_CANCEL_FEE_INVOICE_TYPE,
);
notify.mockRejectedValueOnce(new Error("inbox down"));
await expect(
service.confirmOfflinePayment("inv-1", slip, {}),
).resolves.toBeDefined();
expect(recordPayment).toHaveBeenCalled();
});
it("still requires the bank slip for a cancellation fee", async () => {
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
await expect(

View File

@@ -1,4 +1,9 @@
import { Freight, PaymentReferenceType } from "@edr/types";
import {
Freight,
NotificationAudience,
NotificationType,
PaymentReferenceType,
} from "@edr/types";
import { ConfigService } from "@nestjs/config";
import {
BadRequestException,
@@ -20,6 +25,10 @@ import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wago
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 { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { resolveShippingLineNotifyTarget } from "../notifications/resolve-shipping-line-contact.util";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
@@ -267,6 +276,8 @@ export class BillingService {
private readonly files: FilesService,
private readonly config: ConfigService,
private readonly manualPaymentSettings: ManualPaymentSettingsService,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -769,8 +780,9 @@ export class BillingService {
uploadedByName: input.userName ?? null,
});
return this.recordPayment(invoiceId, {
amount: Number(invoice.balanceAmount),
const amount = Number(invoice.balanceAmount);
const paid = await this.recordPayment(invoiceId, {
amount,
method: "BANK_TRANSFER",
reference: input.reference || slip.name,
metadata: {
@@ -780,6 +792,104 @@ export class BillingService {
confirmedByName: input.userName ?? null,
},
});
// The customer did not pay through the portal, so nothing else tells them
// Finance has settled their invoice — this is their only confirmation.
await this.notifyCustomerManualPaymentConfirmed(paid, amount);
return paid;
}
/**
* Tell the customer Finance confirmed their manual (bank transfer / counter)
* payment: portal inbox entry plus SMS and email to the company's contact
* (or the shipping line's own contact for a credit invoice). Best-effort —
* a notification failure never undoes the settlement, it is only logged.
*/
private async notifyCustomerManualPaymentConfirmed(
invoice: Invoice,
amount: number,
): Promise<void> {
try {
const bookingRef =
invoice.source === Freight.InvoiceSource.Booking
? await this.bookingReferenceFor(invoice.sourceId)
: null;
const body =
`Your payment of ${round2(amount)} ${invoice.currency} for invoice ${invoice.invoiceNumber}` +
(bookingRef ? ` (booking ${bookingRef})` : "") +
` has been received and confirmed. Thank you.`;
const title = "Payment confirmed";
const data = {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
bookingId: bookingRef ? invoice.sourceId : null,
};
if (invoice.companyId || invoice.companyProfileId) {
await this.inbox.notify({
recipients: invoice.companyId
? { companyId: invoice.companyId }
: { companyProfileId: invoice.companyProfileId! },
audience: NotificationAudience.PORTAL,
type: NotificationType.PAYMENT_RECEIVED,
title,
body,
link: `/billing/${invoice.id}`,
data,
});
if (invoice.companyId) {
await sendCompanyChannels(
this.dataSource,
this.notifications,
invoice.companyId,
body,
);
}
return;
}
if (invoice.shippingLineCompanyId) {
const target = await resolveShippingLineNotifyTarget(
this.dataSource,
invoice.shippingLineCompanyId,
);
if (target.userId) {
await this.inbox.notify({
recipients: { userIds: [target.userId] },
audience: NotificationAudience.PORTAL,
type: NotificationType.PAYMENT_RECEIVED,
title,
body,
link: `/shipping-line/invoices/${invoice.id}`,
data,
});
}
for (const [method, to] of [
["sms", target.phone],
["email", target.email],
] as const) {
if (!to) continue;
try {
await this.notifications.directSend(method, to, body);
} catch {
/* best-effort: provider unavailable */
}
}
}
} catch (err) {
this.logger.warn(
`Manual payment confirmed notify failed for invoice ${invoice.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** Booking reference for a booking id, or null when the booking is gone. */
private async bookingReferenceFor(bookingId: string): Promise<string | null> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
select: ["id", "reference"],
});
return booking?.reference ?? null;
}
/** Invoice header plus its line items. */