mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries
This commit is contained in:
@@ -14,9 +14,11 @@ import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingClearanceCharge,
|
||||
ClearanceChargeStatus,
|
||||
ClearanceChargeType,
|
||||
} from './entities/booking-clearance-charge.entity';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
@@ -32,13 +34,22 @@ const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
|
||||
MISCELLANEOUS: 'Miscellaneous charges',
|
||||
};
|
||||
|
||||
/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */
|
||||
export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet<ClearanceChargeStatus> =
|
||||
new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
|
||||
|
||||
/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */
|
||||
export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean =>
|
||||
status !== 'ACCEPTED' && status !== 'PAID';
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charges billed to the customer. Two levels per
|
||||
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
|
||||
* (amount + currency) and sends the invoice; once that invoice is paid GL
|
||||
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
|
||||
* through the portal gateway, other currencies through Finance's manual
|
||||
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
|
||||
* Post-finalization clearance charges billed to the customer: one port charge
|
||||
* (document from GL Djibouti, priced by GL Ethiopia) and any number of
|
||||
* miscellaneous charges. GL prices + describes a charge and SENDs it; the
|
||||
* customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues
|
||||
* the payable invoice and locks the charge. ETB invoices are paid through the
|
||||
* portal gateway, other currencies through Finance's manual settlement
|
||||
* worklist — both settle via `clearance_charge.invoice.paid`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingClearanceChargeService {
|
||||
@@ -51,6 +62,7 @@ export class BookingClearanceChargeService {
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
) {}
|
||||
|
||||
private repo() {
|
||||
@@ -104,6 +116,11 @@ export class BookingClearanceChargeService {
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
amount: c.amount != null ? Number(c.amount) : null,
|
||||
currency: c.currency ?? null,
|
||||
description: c.description ?? null,
|
||||
customerNote: c.customerNote ?? null,
|
||||
customerDecidedAt: c.customerDecidedAt
|
||||
? c.customerDecidedAt.toISOString()
|
||||
: null,
|
||||
invoiceId: c.invoiceId ?? null,
|
||||
invoiceNumber: c.invoiceId
|
||||
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
|
||||
@@ -121,6 +138,24 @@ export class BookingClearanceChargeService {
|
||||
});
|
||||
}
|
||||
|
||||
/** The customer's view: only charges GL has sent them. */
|
||||
async listForCustomer(bookingId: string): Promise<Freight.ClearanceCharge[]> {
|
||||
return (await this.list(bookingId)).filter((c) =>
|
||||
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status),
|
||||
);
|
||||
}
|
||||
|
||||
private async findCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
): Promise<BookingClearanceCharge> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
return charge;
|
||||
}
|
||||
|
||||
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
|
||||
async uploadPortDocument(
|
||||
bookingId: string,
|
||||
@@ -180,22 +215,21 @@ export class BookingClearanceChargeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia sets (or, on the customer's request, revises) amount +
|
||||
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
|
||||
* is immutable.
|
||||
* GL Ethiopia sets (or, after a customer rejection, revises) amount +
|
||||
* currency + description. Allowed until the customer accepts: an ACCEPTED
|
||||
* charge already carries an invoice and a PAID one is settled.
|
||||
*/
|
||||
async billCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
input: { amount: number; currency: string },
|
||||
input: { amount: number; currency: string; description?: string },
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
if (charge.status === 'PAID') {
|
||||
throw new ConflictException('A paid charge can no longer be changed.');
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (!canStaffEditCharge(charge.status)) {
|
||||
throw new ConflictException(
|
||||
'The customer has accepted this charge — it can no longer be changed.',
|
||||
);
|
||||
}
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Amount must be greater than zero.');
|
||||
@@ -203,53 +237,117 @@ export class BookingClearanceChargeService {
|
||||
if (!input.currency?.trim()) {
|
||||
throw new BadRequestException('Currency is required.');
|
||||
}
|
||||
|
||||
if (charge.status === 'SENT' && charge.invoiceId) {
|
||||
await this.billing.cancelInvoice(charge.invoiceId);
|
||||
const description = (input.description ?? charge.description ?? '').trim();
|
||||
if (charge.type === 'MISCELLANEOUS' && !description) {
|
||||
throw new BadRequestException('Describe what this charge is for.');
|
||||
}
|
||||
const currency = input.currency.trim().toUpperCase();
|
||||
const revised = charge.status === 'SENT' || charge.status === 'REJECTED';
|
||||
|
||||
// Back to draft: the customer's previous decision no longer applies.
|
||||
await this.repo().update(charge.id, {
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
currency,
|
||||
description: description || null,
|
||||
status: 'BILLED',
|
||||
invoiceId: null,
|
||||
customerNote: null,
|
||||
customerDecidedAt: null,
|
||||
customerDecidedBy: null,
|
||||
billedByStaffId: staffId,
|
||||
billedAt: new Date(),
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_BILLED',
|
||||
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
|
||||
label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
|
||||
charge.type
|
||||
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`,
|
||||
].toLowerCase()}: ${input.amount} ${currency}${
|
||||
description ? ` — ${description}` : ''
|
||||
}`,
|
||||
actorId: staffId,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
amount: input.amount,
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
revised: charge.status === 'SENT',
|
||||
currency,
|
||||
description: description || null,
|
||||
revised,
|
||||
},
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** GL Ethiopia issues the payable invoice to the customer. */
|
||||
/**
|
||||
* GL Ethiopia proposes the priced charge to the customer. No invoice yet —
|
||||
* that is issued when the customer accepts. Re-sending after a rejection
|
||||
* goes through here too.
|
||||
*/
|
||||
async sendCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
staffId?: string,
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
if (charge.status !== 'BILLED') {
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') {
|
||||
throw new ConflictException(
|
||||
'Set the amount and currency before sending the charge to the customer.',
|
||||
charge.status === 'DOC_UPLOADED'
|
||||
? 'Set the amount and currency before sending the charge to the customer.'
|
||||
: 'This charge has already been sent to the customer.',
|
||||
);
|
||||
}
|
||||
const revised = charge.status === 'REJECTED';
|
||||
const amount = Number(charge.amount);
|
||||
const currency = charge.currency ?? 'ETB';
|
||||
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'SENT',
|
||||
customerNote: null,
|
||||
customerDecidedAt: null,
|
||||
customerDecidedBy: null,
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_SENT',
|
||||
label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[
|
||||
charge.type
|
||||
].toLowerCase()} to the customer for approval: ${amount} ${currency}`,
|
||||
actorId: staffId ?? null,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
amount,
|
||||
currency,
|
||||
description: charge.description ?? null,
|
||||
revised,
|
||||
},
|
||||
});
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.clearanceChargeProposed(booking, {
|
||||
label: CHARGE_LABEL[charge.type],
|
||||
amount,
|
||||
currency,
|
||||
description: charge.description ?? null,
|
||||
revised,
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** Customer agrees to the price: the payable invoice is issued and the charge locks. */
|
||||
async customerAccept(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
userId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (charge.status !== 'SENT' && charge.status !== 'REJECTED') {
|
||||
throw new ConflictException(
|
||||
charge.status === 'ACCEPTED' || charge.status === 'PAID'
|
||||
? 'This charge has already been accepted.'
|
||||
: 'This charge is not awaiting your decision.',
|
||||
);
|
||||
}
|
||||
const amount = Number(charge.amount);
|
||||
const currency = charge.currency ?? 'ETB';
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.ClearanceCharge,
|
||||
// The charge's own id, NOT the booking id — booking-scoped invoice
|
||||
@@ -258,46 +356,101 @@ export class BookingClearanceChargeService {
|
||||
type: charge.type,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: charge.currency ?? 'ETB',
|
||||
currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: charge.type,
|
||||
description: `${CHARGE_LABEL[charge.type]} — ${booking.reference ?? bookingId}`,
|
||||
amount: Number(charge.amount),
|
||||
description: `${CHARGE_LABEL[charge.type]} — ${
|
||||
booking.reference ?? bookingId
|
||||
}${charge.description ? `: ${charge.description}` : ''}`,
|
||||
amount,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'SENT',
|
||||
status: 'ACCEPTED',
|
||||
invoiceId: invoice.id,
|
||||
customerNote: null,
|
||||
customerDecidedAt: new Date(),
|
||||
customerDecidedBy: userId,
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_INVOICE_SENT',
|
||||
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`,
|
||||
actorId: staffId ?? null,
|
||||
action: 'CHARGE_ACCEPTED',
|
||||
label: `Customer accepted ${CHARGE_LABEL[
|
||||
charge.type
|
||||
].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`,
|
||||
actorType: 'CUSTOMER',
|
||||
actorId: userId,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
amount: Number(charge.amount),
|
||||
currency: charge.currency,
|
||||
amount,
|
||||
currency,
|
||||
},
|
||||
});
|
||||
this.notifier.clearanceChargeInvoiceIssued(booking, {
|
||||
label: CHARGE_LABEL[charge.type],
|
||||
amount,
|
||||
currency,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
});
|
||||
this.logger.log(
|
||||
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
|
||||
`Clearance charge ${charge.type} on booking ${bookingId} accepted; invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
return this.list(bookingId);
|
||||
return this.listForCustomer(bookingId);
|
||||
}
|
||||
|
||||
/** Customer declines the price with a reason; GL revises and re-sends. */
|
||||
async customerReject(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
note: string,
|
||||
userId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (charge.status !== 'SENT') {
|
||||
throw new ConflictException(
|
||||
charge.status === 'ACCEPTED' || charge.status === 'PAID'
|
||||
? 'This charge has already been accepted.'
|
||||
: 'This charge is not awaiting your decision.',
|
||||
);
|
||||
}
|
||||
if (!note?.trim()) {
|
||||
throw new BadRequestException('Say why you are rejecting this charge.');
|
||||
}
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'REJECTED',
|
||||
customerNote: note.trim(),
|
||||
customerDecidedAt: new Date(),
|
||||
customerDecidedBy: userId,
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_REJECTED',
|
||||
label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`,
|
||||
actorType: 'CUSTOMER',
|
||||
actorId: userId,
|
||||
metadata: { chargeType: charge.type, note: note.trim() },
|
||||
});
|
||||
this.notifier.clearanceChargeRejectedToStaff(booking, {
|
||||
label: CHARGE_LABEL[charge.type],
|
||||
note: note.trim(),
|
||||
});
|
||||
return this.listForCustomer(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
|
||||
* currency). Second payment level: allowed only once the port charge is paid.
|
||||
* GL Ethiopia creates a miscellaneous charge whole (document + amount +
|
||||
* currency + what it is for). Lands as a BILLED draft; GL sends it next.
|
||||
*/
|
||||
async createMiscellaneous(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
input: { amount: number; currency: string },
|
||||
input: { amount: number; currency: string; description?: string },
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
@@ -311,6 +464,10 @@ export class BookingClearanceChargeService {
|
||||
if (!input.currency?.trim()) {
|
||||
throw new BadRequestException('Currency is required.');
|
||||
}
|
||||
const description = input.description?.trim() ?? '';
|
||||
if (!description) {
|
||||
throw new BadRequestException('Describe what this charge is for.');
|
||||
}
|
||||
|
||||
// Save the row first so its id can key the document. A booking may carry
|
||||
// several miscellaneous charges, and `upsertByCode` retires whatever sits
|
||||
@@ -323,6 +480,7 @@ export class BookingClearanceChargeService {
|
||||
status: 'BILLED',
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
description,
|
||||
uploadedByStaffId: staffId,
|
||||
uploadedAt: new Date(),
|
||||
billedByStaffId: staffId,
|
||||
@@ -342,11 +500,12 @@ export class BookingClearanceChargeService {
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_MISC_CREATED',
|
||||
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`,
|
||||
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()} — ${description}`,
|
||||
actorId: staffId,
|
||||
metadata: {
|
||||
amount: input.amount,
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
description,
|
||||
fileName: file.originalname,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
CUSTOMER_VISIBLE_CHARGE_STATUSES,
|
||||
canStaffEditCharge,
|
||||
} from './booking-clearance-charge.service';
|
||||
import { CLEARANCE_CHARGE_STATUSES } from './entities/booking-clearance-charge.entity';
|
||||
|
||||
describe('clearance charge status guards', () => {
|
||||
it('locks the charge once the customer has accepted or paid', () => {
|
||||
expect(canStaffEditCharge('ACCEPTED')).toBe(false);
|
||||
expect(canStaffEditCharge('PAID')).toBe(false);
|
||||
for (const s of ['DOC_UPLOADED', 'BILLED', 'SENT', 'REJECTED'] as const) {
|
||||
expect(canStaffEditCharge(s)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('hides GL drafts from the customer and shows everything sent', () => {
|
||||
const visible = CLEARANCE_CHARGE_STATUSES.filter((s) =>
|
||||
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(s),
|
||||
);
|
||||
expect(visible).toEqual(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
|
||||
});
|
||||
});
|
||||
@@ -421,6 +421,55 @@ export class BookingLifecycleNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Clearance charges (port + miscellaneous) ───────────────────────────────
|
||||
|
||||
/** GL proposed (or re-proposed) a clearance charge — the customer accepts or rejects it in the portal. */
|
||||
clearanceChargeProposed(
|
||||
b: Booking,
|
||||
c: {
|
||||
label: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description: string | null;
|
||||
revised: boolean;
|
||||
},
|
||||
): void {
|
||||
const msg =
|
||||
`${c.revised ? 'Revised ' + c.label.toLowerCase() : c.label} of ${c.amount} ${c.currency}` +
|
||||
`${c.description ? ` (${c.description})` : ''} on booking ${b.reference} ` +
|
||||
`await your approval. Please accept or reject them in the portal.`;
|
||||
void this.notifyContact(b, msg, c.revised ? 'CLEARANCE CHARGE REVISED' : 'CLEARANCE CHARGE SENT');
|
||||
this.inApp(b, c.revised ? `${c.label} revised` : `${c.label} need your approval`, msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
});
|
||||
}
|
||||
|
||||
/** The customer accepted a clearance charge — its invoice is now payable. */
|
||||
clearanceChargeInvoiceIssued(
|
||||
b: Booking,
|
||||
c: { label: string; amount: number; currency: string; invoiceNumber: string },
|
||||
): void {
|
||||
const msg =
|
||||
`Invoice ${c.invoiceNumber} for ${c.label.toLowerCase()} (${c.amount} ${c.currency}) ` +
|
||||
`on booking ${b.reference} is ready. Please pay it from the portal.`;
|
||||
void this.notifyContact(b, msg, 'CLEARANCE CHARGE INVOICE');
|
||||
this.inApp(b, `${c.label} invoice issued`, msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
});
|
||||
}
|
||||
|
||||
/** The customer rejected a clearance charge — GL Ethiopia revises and re-sends. */
|
||||
clearanceChargeRejectedToStaff(b: Booking, c: { label: string; note: string }): void {
|
||||
const msg =
|
||||
`The customer rejected the ${c.label.toLowerCase()} on booking ${this.ref(b)}: ` +
|
||||
`"${c.note}". Revise and re-send from the clearance page.`;
|
||||
this.inAppStaff(b, `${c.label} rejected — ${this.ref(b)}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/clearance/${b.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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.`;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
/** Invoice statuses a customer can still settle (mirrors the portal's PAYABLE_STATUSES). */
|
||||
const PAYABLE_INVOICE_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
|
||||
/** Booking statuses at which the freight invoice is actually due (mirrors BookingsService). */
|
||||
const FREIGHT_PAYABLE_BOOKING_STATUSES = [
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'AWAITING_PAYMENT',
|
||||
];
|
||||
|
||||
/**
|
||||
* One row per outstanding item. `invoices.status` / `bookings.status` are
|
||||
* Postgres enums, hence the ::text casts. `amount` is NULL for items that only need the
|
||||
* customer's review (a proposed clearance charge, a draft final invoice) so
|
||||
* they count but do not inflate "amount due".
|
||||
*/
|
||||
const SQL = `
|
||||
-- Central invoices on the booking: freight (only while the booking is in a
|
||||
-- payable status), wagon-cancellation fee, GL final invoice (+ its DRAFT,
|
||||
-- which waits for the customer's approval).
|
||||
SELECT i.source_id AS "bookingId", i.currency,
|
||||
CASE WHEN i.status::text = 'DRAFT' THEN NULL ELSE i.balance_amount END AS amount
|
||||
FROM freight.invoices i
|
||||
JOIN freight.bookings b ON b.id::text = i.source_id AND b.deleted_at IS NULL
|
||||
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'booking'
|
||||
AND (
|
||||
(i.status::text = ANY($2::text[]) AND i.balance_amount > 0
|
||||
AND (i.type IN ('WAGON_CANCEL_FEE', 'GL_FINAL') OR b.status::text = ANY($3::text[])))
|
||||
OR (i.type = 'GL_FINAL' AND i.status::text = 'DRAFT')
|
||||
)
|
||||
UNION ALL
|
||||
-- Accepted clearance charges whose invoice is still unpaid.
|
||||
SELECT c.booking_id::text, i.currency, i.balance_amount
|
||||
FROM freight.invoices i
|
||||
JOIN freight.booking_clearance_charge c ON c.id::text = i.source_id AND c.deleted_at IS NULL
|
||||
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'clearance_charge'
|
||||
AND i.status::text = ANY($2::text[]) AND i.balance_amount > 0
|
||||
UNION ALL
|
||||
-- Clearance charges waiting for the customer to accept or reject the price.
|
||||
SELECT c.booking_id::text, c.currency, NULL::numeric
|
||||
FROM freight.booking_clearance_charge c
|
||||
JOIN freight.bookings b ON b.id = c.booking_id AND b.deleted_at IS NULL
|
||||
WHERE b.company_id = $1 AND c.deleted_at IS NULL AND c.status = 'SENT'
|
||||
UNION ALL
|
||||
-- Duty / tax advised by customs, payment slip not uploaded yet.
|
||||
SELECT m.booking_id::text, m.metadata->>'dutyCurrency',
|
||||
NULLIF(m.metadata->>'dutyAmount', '')::numeric
|
||||
FROM freight.clearance_milestones m
|
||||
JOIN freight.bookings b ON b.id = m.booking_id AND b.deleted_at IS NULL
|
||||
WHERE b.company_id = $1 AND m.deleted_at IS NULL AND m.status = 'COMPLETED'
|
||||
AND (
|
||||
(m.milestone_code = 'DUTY_TAXES_ADVISED' AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.clearance_milestones p
|
||||
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'DUTY_TAX_PAID'
|
||||
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
|
||||
OR
|
||||
(m.milestone_code = 'SECOND_DUTY_ADVISED' AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.clearance_milestones p
|
||||
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'SECOND_DUTY_PAID'
|
||||
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
|
||||
)
|
||||
`;
|
||||
|
||||
/**
|
||||
* Everything a customer still has to act on, per booking, in one query. Drives
|
||||
* the "Pay" badge on the home and booking-list rows; the booking's Payments tab
|
||||
* composes the same items client-side from the per-booking endpoints.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingPayablesService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async summarizeForCompany(
|
||||
companyId: string,
|
||||
): Promise<Freight.BookingPayableSummary[]> {
|
||||
const rows: Array<{
|
||||
bookingId: string;
|
||||
currency: string | null;
|
||||
amount: string | null;
|
||||
}> = await this.dataSource.query(SQL, [
|
||||
companyId,
|
||||
PAYABLE_INVOICE_STATUSES,
|
||||
FREIGHT_PAYABLE_BOOKING_STATUSES,
|
||||
]);
|
||||
|
||||
const byBooking = new Map<string, Freight.BookingPayableSummary>();
|
||||
for (const r of rows) {
|
||||
const s = byBooking.get(r.bookingId) ?? {
|
||||
bookingId: r.bookingId,
|
||||
count: 0,
|
||||
totals: [],
|
||||
};
|
||||
s.count += 1;
|
||||
const amount = Number(r.amount ?? 0);
|
||||
if (r.currency && amount > 0) {
|
||||
const t = s.totals.find((x) => x.currency === r.currency);
|
||||
if (t) t.amount += amount;
|
||||
else s.totals.push({ currency: r.currency, amount });
|
||||
}
|
||||
byBooking.set(r.bookingId, s);
|
||||
}
|
||||
return [...byBooking.values()];
|
||||
}
|
||||
}
|
||||
@@ -388,6 +388,27 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
||||
expect(line!.amount).toBe(200);
|
||||
});
|
||||
|
||||
it('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => {
|
||||
const ethiopianFee = {
|
||||
...containerFee20,
|
||||
id: 'rate-et-20',
|
||||
rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
rateValue: 40,
|
||||
} as Rate;
|
||||
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
|
||||
const result = await service.computePriceForBooking(
|
||||
containerBooking({
|
||||
serviceType: { includesCustoms: true, includesEthiopianCustomsOnly: true },
|
||||
} as never),
|
||||
);
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
|
||||
expect(line).toBeDefined();
|
||||
expect(line!.amount).toBe(160);
|
||||
expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false);
|
||||
});
|
||||
|
||||
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
|
||||
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||||
const result = await service.computePriceForBooking(containerBooking());
|
||||
|
||||
@@ -1060,9 +1060,18 @@ export class BookingPricingService {
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||||
|
||||
// An Ethiopian-side-only customs service prices off its own rate; the
|
||||
// contract froze its snapshots under the matching code prefix.
|
||||
const customsType = booking.serviceType?.includesEthiopianCustomsOnly
|
||||
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||
: 'CUSTOMS_CLEARANCE';
|
||||
const customsLabel =
|
||||
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||
? 'Ethiopian customs clearance service'
|
||||
: 'Customs clearance service';
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||
r.rateType === customsType &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === booking.tradeDirection &&
|
||||
r.originYardId === booking.originYardId &&
|
||||
@@ -1070,20 +1079,20 @@ export class BookingPricingService {
|
||||
);
|
||||
const missingRateMessage = (scope: string): string =>
|
||||
`No customs clearance service fee is configured for ${scope} on this ` +
|
||||
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
|
||||
`origin → destination. Ask EDR to configure the ${customsType} rate for this route.`;
|
||||
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
|
||||
const hasPerSizeSnapshot =
|
||||
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
||||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
||||
frozenRates?.has(`${customsType}_20FT`) ||
|
||||
frozenRates?.has(`${customsType}_40FT`);
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
||||
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||
const amount = Number(legacyFlat.unitPrice);
|
||||
if (amount > 0) {
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
description: 'Customs clearance service',
|
||||
code: customsType,
|
||||
description: customsLabel,
|
||||
amount,
|
||||
unitAmount: amount,
|
||||
unit: 'FLAT',
|
||||
@@ -1106,7 +1115,7 @@ export class BookingPricingService {
|
||||
// unknown type — falls through to the live per-type lookup below
|
||||
}
|
||||
const frozen = sizeFt
|
||||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
|
||||
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
|
||||
: null;
|
||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||
if (!frozen && !live) {
|
||||
@@ -1124,8 +1133,8 @@ export class BookingPricingService {
|
||||
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||
if (!(amount > 0)) continue;
|
||||
lineItems.push({
|
||||
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
|
||||
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
|
||||
code: sizeFt ? `${customsType}_${sizeFt}FT` : customsType,
|
||||
description: `${customsLabel}${sizeFt ? ` (${sizeFt}ft)` : ''}`,
|
||||
amount,
|
||||
unitAmount,
|
||||
unit,
|
||||
@@ -1141,7 +1150,7 @@ export class BookingPricingService {
|
||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||
// commodity-less rate (legacy) is the catch-all fallback.
|
||||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
||||
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
||||
const live =
|
||||
(booking.cargoTypeId
|
||||
? onLeg.find(
|
||||
@@ -1172,8 +1181,8 @@ export class BookingPricingService {
|
||||
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||
if (amount > 0) {
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
description: 'Customs clearance service (bulk)',
|
||||
code: customsType,
|
||||
description: `${customsLabel} (bulk)`,
|
||||
amount,
|
||||
unitAmount,
|
||||
unit,
|
||||
|
||||
@@ -40,8 +40,12 @@ import {
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BookingPayablesService } from './booking-payables.service';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
|
||||
import {
|
||||
BillClearanceChargeDto,
|
||||
RejectClearanceChargeDto,
|
||||
} from './dto/clearance-charge.dto';
|
||||
import { AdditionalChargeService } from './additional-charge.service';
|
||||
import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
@@ -177,6 +181,7 @@ export class BookingsController {
|
||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||
private readonly clearanceChargeService: BookingClearanceChargeService,
|
||||
private readonly bookingPayablesService: BookingPayablesService,
|
||||
private readonly clearanceEventService: ClearanceEventService,
|
||||
private readonly additionalChargeService: AdditionalChargeService,
|
||||
) {}
|
||||
@@ -315,6 +320,21 @@ export class BookingsController {
|
||||
return this.bookingsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get("my-payables")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Outstanding customer payments per booking — invoices to pay, prices to accept, duty slips to upload",
|
||||
})
|
||||
async findMyPayables(@CurrentUser() user: AuthUserPayload) {
|
||||
const companyId = await this.bookingsService.resolveCustomerCompanyId(
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return companyId
|
||||
? this.bookingPayablesService.summarizeForCompany(companyId)
|
||||
: [];
|
||||
}
|
||||
|
||||
@Get("my")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
@@ -1121,15 +1141,63 @@ export class BookingsController {
|
||||
// ── Clearance charges (post-finalization customer billing) ────────────────
|
||||
|
||||
@Get(":id/clearance/charges")
|
||||
@BookingStaff([
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary: "Clearance charges billed to the customer (port + miscellaneous)",
|
||||
summary:
|
||||
"Clearance charges billed to the customer (port + miscellaneous); customers see only the charges sent to them",
|
||||
})
|
||||
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.clearanceChargeService.list(id);
|
||||
async getClearanceCharges(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const isStaff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
if (isStaff) return this.clearanceChargeService.list(id);
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
return this.clearanceChargeService.listForCustomer(id);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/:chargeId/accept")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge",
|
||||
})
|
||||
acceptClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceChargeService.customerAccept(
|
||||
id,
|
||||
chargeId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/:chargeId/reject")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends",
|
||||
})
|
||||
rejectClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@Body() dto: RejectClearanceChargeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceChargeService.customerReject(
|
||||
id,
|
||||
chargeId,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/port-document")
|
||||
@@ -1156,7 +1224,7 @@ export class BookingsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
|
||||
"GL Ethiopia sets or revises the charge's amount, currency and description (locked once the customer accepts)",
|
||||
})
|
||||
billClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -1176,7 +1244,7 @@ export class BookingsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
|
||||
"GL Ethiopia sends the priced charge to the customer for approval (the invoice is issued when they accept)",
|
||||
})
|
||||
sendClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -1196,7 +1264,7 @@ export class BookingsController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
|
||||
"GL Ethiopia creates a miscellaneous charge (document + amount + currency + description) as a draft to send",
|
||||
})
|
||||
createMiscellaneousCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -42,6 +42,7 @@ import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||
import { AdditionalChargeRepository } from './additional-charge.repository';
|
||||
import { AdditionalChargeService } from './additional-charge.service';
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BookingPayablesService } from './booking-payables.service';
|
||||
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
@@ -122,6 +123,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingContractService,
|
||||
BookingInvoiceService,
|
||||
BookingClearanceChargeService,
|
||||
BookingPayablesService,
|
||||
ClearanceEventService,
|
||||
AdditionalChargeRepository,
|
||||
AdditionalChargeService,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
|
||||
import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Length,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BillClearanceChargeDto {
|
||||
@ApiProperty({ example: 12500.5 })
|
||||
@@ -13,4 +20,18 @@ export class BillClearanceChargeDto {
|
||||
@IsString()
|
||||
@Length(3, 8)
|
||||
currency!: string;
|
||||
|
||||
/** What the price is for. Required for miscellaneous charges (checked in the service). */
|
||||
@ApiPropertyOptional({ example: 'Container cleaning and weighbridge fee' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class RejectClearanceChargeDto {
|
||||
@ApiProperty({ example: 'The weighbridge fee was already paid at the port.' })
|
||||
@IsString()
|
||||
@Length(1, 1000)
|
||||
note!: string;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export const CLEARANCE_CHARGE_STATUSES = [
|
||||
'DOC_UPLOADED',
|
||||
'BILLED',
|
||||
'SENT',
|
||||
'REJECTED',
|
||||
'ACCEPTED',
|
||||
'PAID',
|
||||
] as const;
|
||||
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||
@@ -17,9 +19,11 @@ export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||
* Clearance charge billed to the customer. One PORT_CHARGES row per booking
|
||||
* (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
|
||||
* GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
|
||||
* sets amount + currency (BILLED) and issues the invoice (SENT); the billing
|
||||
* `clearance_charge.invoice.paid` event marks it PAID. The two levels are
|
||||
* independent — either may be raised first.
|
||||
* sets amount + currency + description (BILLED) and proposes it to the
|
||||
* customer (SENT). The customer either REJECTS with a note (GL revises and
|
||||
* re-sends) or ACCEPTS, which issues the invoice and locks the charge; the
|
||||
* billing `clearance_charge.invoice.paid` event marks it PAID. The two levels
|
||||
* are independent — either may be raised first.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
|
||||
@Index(['bookingId'])
|
||||
@@ -47,6 +51,20 @@ export class BookingClearanceCharge extends BaseEntity {
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
|
||||
currency?: string | null;
|
||||
|
||||
/** What the price is for, written by GL. */
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
/** Customer's reason when REJECTED; cleared when GL revises. */
|
||||
@Column({ name: 'customer_note', type: 'text', nullable: true })
|
||||
customerNote?: string | null;
|
||||
|
||||
@Column({ name: 'customer_decided_at', type: 'timestamptz', nullable: true })
|
||||
customerDecidedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customer_decided_by', type: 'uuid', nullable: true })
|
||||
customerDecidedBy?: string | null;
|
||||
|
||||
/** The payable invoice issued for this charge (null until SENT). */
|
||||
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user