mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
564 lines
22 KiB
TypeScript
564 lines
22 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import { DataSource } from 'typeorm';
|
|
import {
|
|
NotificationAudience,
|
|
NotificationType,
|
|
NotifyInput,
|
|
} from '@edr/types';
|
|
|
|
import { Booking } from './entities/booking.entity';
|
|
import { NotificationsService } from '../notifications/notifications.service';
|
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
|
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
|
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
|
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
|
|
|
/**
|
|
* Clearance items are worked by the GL desks, which hold no bookings:view and
|
|
* no intake keys — so they take their own selector rather than the booking
|
|
* desk's. Every override using this deep-links to a clearance page.
|
|
*/
|
|
const CLEARANCE_DESK = {
|
|
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
|
|
};
|
|
|
|
/**
|
|
* Customer + staff notifications for the booking lifecycle: review, clearance
|
|
* and operation flow. Every customer event fans out over SMS + email (direct)
|
|
* and a persisted in-app notification deep-linking to the booking detail page;
|
|
* staff events land in the backoffice inbox. All sends are fire-and-forget and
|
|
* never throw — a notification failure must not break a booking transition.
|
|
*
|
|
* NOTE: the batch/payment-window notifications (pay-now, allocated, expired,
|
|
* displaced) are handled separately by {@link BookingNotifierService} in
|
|
* train-scheduling.
|
|
*/
|
|
@Injectable()
|
|
export class BookingLifecycleNotifierService {
|
|
private readonly logger = new Logger(BookingLifecycleNotifierService.name);
|
|
|
|
constructor(
|
|
private readonly notifications: NotificationsService,
|
|
private readonly inbox: NotificationInboxService,
|
|
@InjectDataSource()
|
|
private readonly dataSource: DataSource,
|
|
) {}
|
|
|
|
private ref(b: Booking): string {
|
|
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
|
}
|
|
|
|
/** Send SMS + email to the booking's company contact; log-only on failure. */
|
|
private async notifyContact(
|
|
b: Booking,
|
|
message: string,
|
|
logLabel: string,
|
|
): Promise<void> {
|
|
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
|
// Both channels come from the same resolver: the company row's own columns
|
|
// are only half the story (see companyNotifyEmailExpr), and reading them off
|
|
// the loaded entity silently dropped every mail to a company whose address
|
|
// lives in `attributes`. A shipping-line booking has NO company — its
|
|
// contact lives on the shipping_line_companies row itself.
|
|
const { phone, email } = b.shippingLineCompanyId
|
|
? await resolveShippingLineNotifyTarget(
|
|
this.dataSource,
|
|
b.shippingLineCompanyId,
|
|
)
|
|
: b.companyId
|
|
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
|
: { phone: null, email: null };
|
|
|
|
if (phone) {
|
|
try {
|
|
await this.notifications.directSend('sms', phone, message);
|
|
} catch (err) {
|
|
this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
if (email) {
|
|
try {
|
|
await this.notifications.directSend('email', email, message);
|
|
} catch (err) {
|
|
this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
if (!phone && !email) {
|
|
this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist + push an in-app item to the booking's portal owner: every portal
|
|
* user of the company, or — for a shipping-line booking — the line's own
|
|
* account, deep-linked into the shipping-line app rather than the customer
|
|
* one (its routes live under /shipping-line/*).
|
|
*/
|
|
private inApp(
|
|
b: Booking,
|
|
title: string,
|
|
body: string,
|
|
overrides: Partial<NotifyInput> = {},
|
|
): void {
|
|
if (b.shippingLineCompanyId) {
|
|
void (async () => {
|
|
const { userId } = await resolveShippingLineNotifyTarget(
|
|
this.dataSource,
|
|
b.shippingLineCompanyId!,
|
|
);
|
|
if (!userId) return;
|
|
void this.inbox.notify({
|
|
recipients: { userIds: [userId] },
|
|
audience: NotificationAudience.PORTAL,
|
|
type: NotificationType.BOOKING_STATUS,
|
|
title,
|
|
body,
|
|
data: { bookingId: b.id, reference: b.reference },
|
|
...overrides,
|
|
// After the spread: overrides carry customer links — the bell must
|
|
// land a shipping line on ITS booking page.
|
|
link: `/shipping-line/bookings/${b.id}`,
|
|
});
|
|
})().catch((err) =>
|
|
this.logger.warn(
|
|
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (!b.companyId) return; // government/unlinked bookings have no portal users
|
|
void this.inbox.notify({
|
|
recipients: { companyId: b.companyId },
|
|
audience: NotificationAudience.PORTAL,
|
|
type: NotificationType.BOOKING_STATUS,
|
|
title,
|
|
body,
|
|
link: `/bookings/${b.id}`,
|
|
data: { bookingId: b.id, reference: b.reference },
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Persist + push an in-app item to the booking desk — staff holding
|
|
* `bookings:get_notification`. Callers whose item belongs to a different desk
|
|
* override `recipients` (see {@link CLEARANCE_DESK}).
|
|
*/
|
|
private inAppStaff(
|
|
b: Booking,
|
|
title: string,
|
|
body: string,
|
|
overrides: Partial<NotifyInput> = {},
|
|
): void {
|
|
void this.inbox.notify({
|
|
recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
|
|
audience: NotificationAudience.BACKOFFICE,
|
|
type: NotificationType.REQUEST_SUBMITTED,
|
|
title,
|
|
body,
|
|
link: `/dashboard/booking-requests/${b.id}`,
|
|
data: { bookingId: b.id, reference: b.reference },
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
// ── Customer-facing lifecycle events ───────────────────────────────────────
|
|
|
|
/** Line staff accepted intake → booking is under approval. */
|
|
accepted(b: Booking): void {
|
|
const msg =
|
|
`Your booking ${b.reference} has been accepted and is now under approval. ` +
|
|
`We will notify you once it is approved.`;
|
|
void this.notifyContact(b, msg, 'ACCEPTED');
|
|
this.inApp(b, 'Booking accepted', msg);
|
|
}
|
|
|
|
/** All approval steps complete → contract generated, ready for customer to sign. */
|
|
approved(b: Booking): void {
|
|
const msg =
|
|
`Your booking ${b.reference} has been approved. ` +
|
|
`Please review and sign your contract from the portal.`;
|
|
void this.notifyContact(b, msg, 'APPROVED');
|
|
this.inApp(b, 'Booking approved', msg);
|
|
}
|
|
|
|
/** Staff rejected the booking (intake or approval step). */
|
|
rejected(b: Booking, reason: string): void {
|
|
const msg =
|
|
`Your booking ${b.reference} was rejected. Reason: ${reason}. ` +
|
|
`Please contact us for details.`;
|
|
void this.notifyContact(b, msg, 'REJECTED');
|
|
this.inApp(b, 'Booking rejected', msg);
|
|
}
|
|
|
|
/** Staff requested changes before approval. */
|
|
changesRequested(b: Booking, note: string): void {
|
|
const msg =
|
|
`Changes were requested on your booking ${b.reference}: ${note}. ` +
|
|
`Please update and resubmit from the portal.`;
|
|
void this.notifyContact(b, msg, 'CHANGES REQUESTED');
|
|
this.inApp(b, 'Booking changes requested', msg);
|
|
}
|
|
|
|
/** A clearance document was queried and needs the customer to re-upload. */
|
|
documentQueried(b: Booking, fileKey: string, note: string): void {
|
|
const msg =
|
|
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
|
|
`${note}. Please re-upload from the portal.`;
|
|
void this.notifyContact(b, msg, 'DOCUMENT QUERIED');
|
|
this.inApp(b, 'Document queried', msg, {
|
|
type: NotificationType.DOCUMENT_ACTION,
|
|
});
|
|
}
|
|
|
|
/** Document approval finalized → customer can proceed to request operation. */
|
|
clearanceReady(b: Booking): void {
|
|
// A shipping line's next move is BOOKING (cargo + shipment day), not the
|
|
// customer's operation-request step — say so, or the message points at a
|
|
// flow their portal does not have.
|
|
const msg = b.shippingLineCompanyId
|
|
? `Documents for booking ${b.reference} are approved. ` +
|
|
`You can now book your shipment — enter the cargo and shipment day from the portal.`
|
|
: `Document approval for booking ${b.reference} is finalized. ` +
|
|
`You can now proceed to request operation from the portal.`;
|
|
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
|
|
this.inApp(
|
|
b,
|
|
b.shippingLineCompanyId
|
|
? 'Documents approved — book your shipment'
|
|
: 'Document approval finalized',
|
|
msg,
|
|
{ type: NotificationType.CLEARANCE_DECISION },
|
|
);
|
|
}
|
|
|
|
/** Intercity documents approved → booking waits in the ride-along pool. */
|
|
intercityDocumentsApproved(b: Booking): void {
|
|
const msg =
|
|
`Documents for intercity booking ${b.reference} are approved. ` +
|
|
`Operations will assign your shipment to a passing train; payment opens once it is accepted.`;
|
|
void this.notifyContact(b, msg, 'DOCUMENTS APPROVED');
|
|
this.inApp(b, 'Documents approved', msg, {
|
|
type: NotificationType.CLEARANCE_DECISION,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Operations returned the operation request for changes.
|
|
*
|
|
* A customs (Path B) booking is completed by GL Ethiopia on the customer's
|
|
* behalf regardless of who opened the shipment instance — the customer-opened
|
|
* ONE_TIME case (see contract-booking.service assertGate) still stamps
|
|
* createdByRole 'CUSTOMER', so gate on customsClearingEnabled, not on who
|
|
* created it. The customer cannot edit or resubmit a customs booking, so
|
|
* telling them to "update from the portal" is a dead end. Those go to the GL
|
|
* who created it when known, else the clearance desk, linking the contract
|
|
* clearance page they work from. Everything else (customer-made bookings)
|
|
* keeps the portal message.
|
|
*/
|
|
operationChangesRequested(b: Booking, note: string): void {
|
|
if (b.customsClearingEnabled) {
|
|
const msg =
|
|
`Operations returned booking ${b.reference} for changes: ${note}. ` +
|
|
`Address it on the contract clearance page and resubmit to Operations.`;
|
|
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
|
|
void this.inbox.notify({
|
|
recipients:
|
|
b.createdByRole === 'GL_ET' && b.createdByUserId
|
|
? { userIds: [b.createdByUserId] }
|
|
: CLEARANCE_DESK,
|
|
audience: NotificationAudience.BACKOFFICE,
|
|
type: NotificationType.BOOKING_STATUS,
|
|
title: `Booking ${b.reference} needs changes`,
|
|
body: msg,
|
|
link: b.contractId
|
|
? `/dashboard/contracts/clearance/${b.contractId}`
|
|
: `/dashboard/bookings/${b.id}/clearance`,
|
|
data: { bookingId: b.id, reference: b.reference, note },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const msg =
|
|
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
|
|
`Please update and resubmit from the portal.`;
|
|
void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED');
|
|
this.inApp(b, 'Operation request needs changes', msg);
|
|
}
|
|
|
|
/** Operation accepted → invoice ready; await payment / booking window. */
|
|
operationAccepted(b: Booking): void {
|
|
// No invoice and no pay window for a shipping line — the charge sits on
|
|
// its credit account and the booking boards its dedicated train directly.
|
|
const msg = b.shippingLineCompanyId
|
|
? `Your booking ${b.reference} has been accepted. The charge has been ` +
|
|
`recorded on your credit account and your shipment is being placed on its train.`
|
|
: `Your operation request for booking ${b.reference} has been accepted. ` +
|
|
`An invoice has been prepared — watch for the payment window to secure your slot.`;
|
|
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
|
|
this.inApp(
|
|
b,
|
|
b.shippingLineCompanyId ? 'Booking accepted' : 'Operation request accepted',
|
|
msg,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* GL Ethiopia created this booking on the customer's behalf. On a customs
|
|
* (Path B) contract the customer never books themselves, so without this they
|
|
* would have no signal that their shipment now exists and is priced.
|
|
*/
|
|
createdByGlForCustomer(b: Booking): void {
|
|
const total = Number(b.totalAmount ?? 0);
|
|
const priced =
|
|
total > 0
|
|
? ` The total is ${total.toLocaleString()} ${b.paymentCurrency}.`
|
|
: '';
|
|
const msg =
|
|
`Global Logistics has created shipment ${b.reference} under your contract.${priced} ` +
|
|
`You can review it in the portal.`;
|
|
void this.notifyContact(b, msg, 'CREATED BY GL');
|
|
this.inApp(b, 'Shipment created for you', msg);
|
|
}
|
|
|
|
/** Shipment started → in transit. */
|
|
inTransit(b: Booking): void {
|
|
const msg = `Your shipment for booking ${b.reference} is now in transit.`;
|
|
void this.notifyContact(b, msg, 'IN TRANSIT');
|
|
this.inApp(b, 'Shipment in transit', msg);
|
|
}
|
|
|
|
/** Shipment delivered → completed. */
|
|
completed(b: Booking): void {
|
|
const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`;
|
|
void this.notifyContact(b, msg, 'COMPLETED');
|
|
this.inApp(b, 'Shipment delivered', msg);
|
|
}
|
|
|
|
/** Booking cancelled. */
|
|
cancelled(b: Booking, reason: string): void {
|
|
const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`;
|
|
void this.notifyContact(b, msg, 'CANCELLED');
|
|
this.inApp(b, 'Booking cancelled', msg);
|
|
}
|
|
|
|
/**
|
|
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
|
|
* deep-linked to the Djibouti clearance page where the name is entered — the
|
|
* customs declaration is blocked until they answer.
|
|
*/
|
|
transitAssigneeRequested(b: Booking, note: string | null): void {
|
|
const msg =
|
|
`GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` +
|
|
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
|
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
|
|
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
|
|
recipients: CLEARANCE_DESK,
|
|
type: NotificationType.CLEARANCE_REVIEW,
|
|
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
|
|
});
|
|
}
|
|
|
|
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
|
|
transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void {
|
|
const msg = previous
|
|
? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` +
|
|
`"${previous}" to "${assignee}".`
|
|
: `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
|
|
`The customs declaration can now be filed.`;
|
|
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
|
|
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
|
|
recipients: CLEARANCE_DESK,
|
|
type: NotificationType.CLEARANCE_REVIEW,
|
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
|
});
|
|
}
|
|
|
|
// ── Clearance milestones needing customer action ──────────────────────────
|
|
|
|
/** GL advised duty & tax — the customer must pay and upload the slip. */
|
|
dutyAdvised(b: Booking, amount: number, currency: string): void {
|
|
const msg =
|
|
`Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
|
|
`Please pay and upload the payment slip from the portal.`;
|
|
void this.notifyContact(b, msg, 'DUTY ADVISED');
|
|
this.inApp(b, 'Duty & tax advised', msg, {
|
|
type: NotificationType.INVOICE_ISSUED,
|
|
});
|
|
}
|
|
|
|
/** GL advised the post-arrival additional duty round (import). */
|
|
secondDutyAdvised(b: Booking, amount: number, currency: string): void {
|
|
const msg =
|
|
`Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
|
|
`Please pay and upload the payment slip from the portal.`;
|
|
void this.notifyContact(b, msg, 'SECOND DUTY ADVISED');
|
|
this.inApp(b, 'Additional duty & tax advised', msg, {
|
|
type: NotificationType.INVOICE_ISSUED,
|
|
});
|
|
}
|
|
|
|
/** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */
|
|
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
|
|
const msg =
|
|
`A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` +
|
|
`Please review and approve it in the portal, then pay and upload the payment slip.`;
|
|
void this.notifyContact(b, msg, 'FINAL INVOICE');
|
|
this.inApp(b, 'Final invoice issued', msg, {
|
|
type: NotificationType.INVOICE_ISSUED,
|
|
});
|
|
}
|
|
|
|
/** GL confirmed the final-invoice payment slip. */
|
|
finalInvoicePaid(b: Booking): void {
|
|
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;
|
|
void this.notifyContact(b, msg, 'FINAL INVOICE PAID');
|
|
this.inApp(b, 'Final invoice paid', msg, {
|
|
type: NotificationType.INVOICE_ISSUED,
|
|
});
|
|
}
|
|
|
|
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
|
|
|
/**
|
|
* A booking was created under a contract. Contract drawdowns never pass
|
|
* through submit, so this is the only point at which staff learn the booking
|
|
* exists — {@link submittedToStaff} covers the direct-booking flow instead.
|
|
*/
|
|
createdToStaff(b: Booking): void {
|
|
this.inAppStaff(
|
|
b,
|
|
'New booking created',
|
|
`Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`,
|
|
);
|
|
}
|
|
|
|
/** Customer submitted a booking for review. */
|
|
submittedToStaff(b: Booking): void {
|
|
this.inAppStaff(
|
|
b,
|
|
'New booking submitted',
|
|
`Booking ${this.ref(b)} was submitted and is awaiting intake review.`,
|
|
);
|
|
}
|
|
|
|
/** Customer approved the GL Djibouti final invoice — payment slip can follow. */
|
|
finalInvoiceApprovedToStaff(b: Booking): void {
|
|
this.inAppStaff(
|
|
b,
|
|
'Final invoice approved',
|
|
`The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`,
|
|
);
|
|
}
|
|
|
|
/** Customer signed the booking contract. */
|
|
customerSignedToStaff(b: Booking): void {
|
|
this.inAppStaff(
|
|
b,
|
|
'Customer signed booking contract',
|
|
`The contract for booking ${this.ref(b)} was signed by the customer.`,
|
|
);
|
|
}
|
|
|
|
/** Customer requested operation (picked a shipment day). */
|
|
operationRequestedToStaff(b: Booking): void {
|
|
this.inAppStaff(
|
|
b,
|
|
'Operation requested',
|
|
`Booking ${this.ref(b)} requested operation — review capacity, documents and route.`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A shared-wagon pairing is waiting for a human decision. Two customers' cargo
|
|
* on one wagon is a commercial call, so this never auto-advances.
|
|
*/
|
|
consolidationApprovalRequestedToStaff(b: Booking, partnerReference: string): void {
|
|
this.inAppStaff(
|
|
b,
|
|
'Shared wagon needs approval',
|
|
`Booking ${this.ref(b)} shares a wagon with ${partnerReference} — approve the consolidation before it reaches Operations.`,
|
|
);
|
|
}
|
|
|
|
/** The pairing was approved; both halves move on to Operations together. */
|
|
consolidationApprovedToStaff(b: Booking, partnerReference: string): void {
|
|
this.inAppStaff(
|
|
b,
|
|
'Shared wagon approved',
|
|
`The shared wagon for ${this.ref(b)} and ${partnerReference} was approved — both bookings are now with Operations.`,
|
|
);
|
|
}
|
|
|
|
/** The pairing was rejected; both halves go back to GL for changes. */
|
|
consolidationRejectedToStaff(b: Booking, partnerReference: string, reason: string): void {
|
|
this.inAppStaff(
|
|
b,
|
|
'Shared wagon rejected',
|
|
`The shared wagon for ${this.ref(b)} and ${partnerReference} was rejected: ${reason}`,
|
|
);
|
|
}
|
|
|
|
/** Customer uploaded clearance documents — review is next. */
|
|
clearanceDocsUploadedToStaff(b: Booking): void {
|
|
this.inAppStaff(
|
|
b,
|
|
'Clearance documents uploaded',
|
|
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
|
|
{
|
|
recipients: CLEARANCE_DESK,
|
|
type: NotificationType.CLEARANCE_REVIEW,
|
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
|
},
|
|
);
|
|
}
|
|
|
|
/** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */
|
|
draftDeclarationReady(b: Booking, price: number, currency: string): void {
|
|
const msg =
|
|
`A draft customs declaration for booking ${b.reference} is ready for your review — ` +
|
|
`estimated price ${price} ${currency}. Please accept it or request a change from the portal.`;
|
|
void this.notifyContact(b, msg, 'DRAFT DECLARATION READY');
|
|
this.inApp(b, 'Draft declaration ready for review', msg, {
|
|
type: NotificationType.DOCUMENT_ACTION,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The customer asked for a change on the draft declaration. This goes to
|
|
* STAFF, not the customer: GL Ethiopia is the one who has to send a
|
|
* corrected draft, and the clearance page is where they do it.
|
|
*/
|
|
draftDeclarationChangeRequested(b: Booking, note: string): void {
|
|
const msg =
|
|
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
|
|
`"${note}". Send a corrected draft from the clearance page.`;
|
|
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
|
|
recipients: CLEARANCE_DESK,
|
|
type: NotificationType.CLEARANCE_REVIEW,
|
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
|
});
|
|
}
|
|
|
|
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
|
|
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
|
|
const label =
|
|
round === 'final'
|
|
? 'final invoice'
|
|
: round === 'second'
|
|
? 'additional duty & tax'
|
|
: 'duty & tax';
|
|
this.inAppStaff(
|
|
b,
|
|
'Payment slip uploaded',
|
|
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
|
|
{
|
|
recipients: CLEARANCE_DESK,
|
|
type: NotificationType.PAYMENT_RECEIVED,
|
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
|
},
|
|
);
|
|
}
|
|
}
|