diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 06849d560..d29d52c42 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index a3ed3e417..52ed67186 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 175e3e00c..c4804ef33 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 { + 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 { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + select: ["id", "reference"], + }); + return booking?.reference ?? null; } /** Invoice header plus its line items. */ diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 9655a9603..4447da9e6 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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 { 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( diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index af8f6cbd8..e09bb1713 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2814,6 +2814,11 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.finalizeClearance, FREIGHT_PERMS.contracts.createBooking, + // GL rebooks cancelled-wagon credits on the customer's behalf — whoever + // cancelled (customer or staff) and whichever side was at fault. Needs to + // see the ledger rows and to redeem the credit. + FREIGHT_PERMS.bookings.wagonCancellationView, + FREIGHT_PERMS.bookings.wagonCancellationRebook, FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDutyAdvise, FREIGHT_PERMS.contracts.finalInvoiceConfirm, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx new file mode 100644 index 000000000..2d37be840 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -0,0 +1,235 @@ +import { useEffect, useState } from "react"; +import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/auth/http"; +import { toDayString } from "@/hooks/useListControls"; +import { formatMoney } from "@/lib/format"; +import { + hasOddFt20, + type RebookPartnerCandidate, + type WagonCancellation, +} from "./types"; + +/** Editable rebook unit — prefilled from the cancelled snapshot. */ +interface RebookUnitDraft { + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: number | ""; +} + +const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] => + (r.cancelledQuantities?.units ?? []).map((u) => ({ + containerSize: u.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? "", + vgmTons: Number(u.vgmTons) || "", + })); + +const containersPayload = (drafts: RebookUnitDraft[]) => { + const bySize = new Map(); + for (const d of drafts) { + bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]); + } + return [...bySize.entries()].map(([containerSize, units]) => ({ + containerSize, + units: units.map((u) => ({ + containerNumber: u.containerNumber.trim(), + ...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}), + ...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}), + })), + })); +}; + +/** + * Staff/GL rebook of a CREDIT_AVAILABLE wagon cancellation: pick the shipment + * day, correct container details if they changed, and — for an odd-20ft + * credit — pick the consolidation partner that shares the wagon. The server + * creates the new booking under the contract and marks it PAID from the credit. + * Used by the wagon-cancellations list, the GL clearance page and the staff + * booking page, so every desk gets the same flow. + */ +export function RebookWagonCancellationModal({ + cancellation, + onClose, + onRebooked, +}: { + cancellation: WagonCancellation | null; + onClose: () => void; + /** Called after a successful rebook with the new booking id (when the API returns it). */ + onRebooked?: (result: { bookingId?: string }) => void; +}) { + const [date, setDate] = useState(null); + const [partnerId, setPartnerId] = useState(null); + const [drafts, setDrafts] = useState([]); + + // Fresh form per row: the modal instance is long-lived on the host page. + useEffect(() => { + setDate(null); + setPartnerId(null); + setDrafts(cancellation ? draftsFrom(cancellation) : []); + }, [cancellation]); + + const needsPartner = cancellation ? hasOddFt20(cancellation) : false; + const partners = useQuery({ + queryKey: [ + "wagon-cancellations", + cancellation?.id, + "rebook-partners", + date ? toDayString(date) : null, + ], + enabled: Boolean(cancellation && needsPartner && date), + queryFn: async () => { + const res = await api.get( + `/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`, + { params: { scheduledDate: toDayString(date!) } }, + ); + return res.data; + }, + }); + + const rebook = useMutation({ + mutationFn: async () => { + const res = await api.post<{ bookingId?: string }>( + `/bookings/wagon-cancellations/${cancellation!.id}/rebook`, + { + scheduledDate: toDayString(date!), + ...(drafts.length ? { containers: containersPayload(drafts) } : {}), + ...(partnerId ? { partnerBookingId: partnerId } : {}), + }, + ); + return res.data ?? {}; + }, + }); + + const patchDraft = (i: number, patch: Partial) => + setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x))); + + return ( + + {cancellation && ( + + + {cancellation.booking?.reference ?? cancellation.bookingId} ·{" "} + {cancellation.wagonsCancelled} wagon(s) · credit{" "} + {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} + + { + setDate(v ? new Date(v) : null); + setPartnerId(null); + }} + minDate={new Date()} + radius="md" + /> + {needsPartner && ( + ({ - value: c.id, - label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`, - }))} - value={rebookPartnerId} - onChange={setRebookPartnerId} - disabled={!rebookDate} - searchable - radius="md" - /> - )} - {rebookNeedsPartner && - rebookDate && - !rebookPartners.isLoading && - (rebookPartners.data ?? []).length === 0 && ( - - No odd-20ft booking rides that day — pick another day or wait - for a partner booking. - - )} - {rebookDrafts.length > 0 && ( - - - Correct the container details if they changed — sizes and - quantities stay as cancelled. - - {rebookDrafts.map((d, i) => ( - - { - const v = e.currentTarget.value; - setRebookDrafts((prev) => - prev.map((x, idx) => - idx === i ? { ...x, containerNumber: v } : x, - ), - ); - }} - size="xs" - radius="md" - style={{ flex: 1.4 }} - /> - { - const v = e.currentTarget.value; - setRebookDrafts((prev) => - prev.map((x, idx) => - idx === i ? { ...x, sealNumber: v } : x, - ), - ); - }} - size="xs" - radius="md" - style={{ flex: 1 }} - /> - { - const raw = e.currentTarget.value; - setRebookDrafts((prev) => - prev.map((x, idx) => - idx === i - ? { ...x, vgmTons: raw === "" ? "" : Number(raw) } - : x, - ), - ); - }} - size="xs" - radius="md" - style={{ width: 90 }} - /> - - ))} - - )} - - - - - - )} - + onRebooked={() => void refetch()} + /> ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx index cb7348433..56a4f3972 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx @@ -311,7 +311,7 @@ export function WagonCancellationCard({ {isCustoms || oddFt20Credit ? ( {isCustoms - ? "This is a customs-cleared booking — Global Logistics will rebook the credit for you." + ? "This is a customs-cleared booking — Global Logistics (EDR staff) will rebook the credit for you on a coming train day. Contact them if you have a preferred date." : "Your credit includes an odd 20ft container that must share a wagon with another booking — Global Logistics will rebook it for you and pair the wagon. Please contact EDR staff."} ) : (