mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
feat(freight): GL rebook of cancelled wagons, seal+train required on completion, train/voyage in SMS
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -664,9 +664,11 @@ export class BookingsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// GL (createBooking) rebooks credits and must see the ledger for that.
|
||||
const staff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||
if (!staff) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -789,6 +791,14 @@ export class BookingsController {
|
||||
staffPermission: string,
|
||||
): Promise<void> {
|
||||
if (hasFreightPermission(user, staffPermission)) return;
|
||||
// Rebooking a credit creates a booking under the contract — GL's booking
|
||||
// creation key covers it even where the dedicated rebook key was never granted.
|
||||
if (
|
||||
staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook &&
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const row = await this.wagonCancellationService.findById(cancellationId);
|
||||
const booking = await this.bookingsService.findById(row.bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
|
||||
Reference in New Issue
Block a user