Merge pull request #1366 from Tria-plc/eims-bulk-register

Additional Charges
This commit is contained in:
Hagernesh Tadesse
2026-08-20 16:14:55 +03:00
committed by GitHub
18 changed files with 1239 additions and 2 deletions

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Ad-hoc customer charges finance raises against a booking — Additional Payments tab. */
export class AdditionalCharge3620000000000 implements MigrationInterface {
name = 'AdditionalCharge3620000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."additional_charge" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
"booking_id" uuid NOT NULL,
"reason" text NOT NULL,
"status" character varying(20) NOT NULL DEFAULT 'DRAFT',
"amount" numeric(14,2) NOT NULL,
"currency" character varying(8) NOT NULL,
"file_record_id" uuid,
"invoice_id" uuid,
"payment_reference" character varying(64),
"created_by_staff_id" uuid,
"sent_by_staff_id" uuid,
"sent_at" timestamptz,
"paid_at" timestamptz,
"cancelled_by_staff_id" uuid,
"cancelled_at" timestamptz,
"cancel_reason" text,
CONSTRAINT "pk_additional_charge" PRIMARY KEY ("id"),
CONSTRAINT "fk_additional_charge_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_additional_charge_booking"
ON "freight"."additional_charge" ("booking_id")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`);
}
}

View File

@@ -13,6 +13,7 @@ import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { AdditionalCharge } from "../bookings/entities/additional-charge.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
@@ -1958,6 +1959,14 @@ export class BillingService {
.getRepository(Booking)
.update({ id: invoice.sourceId }, { pnrCode: billReference });
}
// Same reference, for an ad-hoc additional charge — its own column, since
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
// can carry many of these at once.
if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) {
await this.dataSource
.getRepository(AdditionalCharge)
.update({ id: invoice.sourceId }, { paymentReference: billReference });
}
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept for local demos only.

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AdditionalCharge } from './entities/additional-charge.entity';
@Injectable()
export class AdditionalChargeRepository extends BaseRepository<AdditionalCharge> {
constructor(@InjectRepository(AdditionalCharge) repository: Repository<AdditionalCharge>) {
super(repository);
}
}

View File

@@ -0,0 +1,281 @@
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource, EntityManager } from 'typeorm';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FilesService } from '../files/files.service';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsService } from './bookings.service';
import { BookingsRepository } from './bookings.repository';
import { AdditionalChargeRepository } from './additional-charge.repository';
import { AdditionalCharge } from './entities/additional-charge.entity';
import { CreateAdditionalChargeDto } from './dto/additional-charge.dto';
const FILE_RESOURCE = 'additional_charges';
/**
* Ad-hoc extra charges finance raises against a booking, independent of
* `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS
* row per booking). Any number per booking, free-text reason. DRAFT until
* sent; sending issues the payable invoice and notifies the customer
* (in-app + SMS + email). Settles via `additional_charge.invoice.paid`,
* same event-driven pattern as every other invoice source.
*/
@Injectable()
export class AdditionalChargeService {
private readonly logger = new Logger(AdditionalChargeService.name);
constructor(
private readonly dataSource: DataSource,
private readonly repository: AdditionalChargeRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly billing: BillingService,
private readonly bookingsService: BookingsService,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private async findOwned(bookingId: string, chargeId: string): Promise<AdditionalCharge> {
const charge = await this.repository.findById(chargeId);
if (!charge || charge.bookingId !== bookingId) {
throw new NotFoundException('Additional charge not found');
}
return charge;
}
async list(bookingId: string): Promise<Freight.AdditionalCharge[]> {
const rows = await this.repository.findAll({
where: { bookingId },
order: { createdAt: 'DESC' },
});
return this.toDtoList(rows);
}
async create(
bookingId: string,
dto: CreateAdditionalChargeDto,
staffId: string,
file?: Express.Multer.File,
): Promise<Freight.AdditionalCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
const shouldSend = dto.action === 'send';
const chargeId = await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(AdditionalCharge);
let saved = await repo.save(
repo.create({
bookingId,
reason: dto.reason.trim(),
amount: dto.amount.toFixed(2),
currency: dto.currency.trim().toUpperCase(),
status: 'DRAFT',
createdByStaffId: staffId,
}),
);
if (file) {
const record = await this.filesService.upload({
resourceId: saved.id,
resource: FILE_RESOURCE,
code: FILE_RESOURCE,
file,
uploadedByUserId: staffId,
});
await repo.update(saved.id, { fileRecordId: record.id });
}
if (shouldSend) {
saved = await this.issueInvoice(manager, saved.id, booking, staffId);
}
return saved.id;
});
if (shouldSend) await this.notifyCustomerSent(chargeId);
return this.list(bookingId);
}
async send(bookingId: string, chargeId: string, staffId: string): Promise<Freight.AdditionalCharge[]> {
const charge = await this.findOwned(bookingId, chargeId);
if (charge.status !== 'DRAFT') {
throw new ConflictException('Only a draft charge can be sent.');
}
const booking = await this.bookingsService.findById(bookingId);
await this.dataSource.transaction((manager) =>
this.issueInvoice(manager, charge.id, booking, staffId),
);
await this.notifyCustomerSent(charge.id);
return this.list(bookingId);
}
/** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */
private async issueInvoice(
manager: EntityManager,
chargeId: string,
booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null },
staffId: string,
): Promise<AdditionalCharge> {
const repo = manager.getRepository(AdditionalCharge);
const charge = await repo.findOneByOrFail({ id: chargeId });
const invoice = await this.billing.generateInvoice(
{
source: Freight.InvoiceSource.AdditionalCharge,
sourceId: charge.id,
type: 'ADDITIONAL_CHARGE',
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: charge.currency,
lines: [
{
chargeType: 'ADDITIONAL_CHARGE',
description: `${charge.reason}${booking.reference ?? booking.id}`,
amount: Number(charge.amount),
},
],
},
manager,
);
await repo.update(charge.id, {
status: 'SENT',
invoiceId: invoice.id,
sentByStaffId: staffId,
sentAt: new Date(),
});
this.logger.log(
`Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`,
);
return repo.findOneByOrFail({ id: charge.id });
}
private async notifyCustomerSent(chargeId: string): Promise<void> {
try {
const charge = await this.repository.findById(chargeId);
if (!charge) return;
const booking = await this.bookingsService.findById(charge.bookingId);
if (!booking.companyId) return;
const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.INVOICE_ISSUED,
title: 'New charge on your booking',
body,
link: `/bookings/${charge.bookingId}`,
data: {
bookingId: charge.bookingId,
chargeId: charge.id,
amount: Number(charge.amount),
currency: charge.currency,
},
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`);
}
}
async cancel(
bookingId: string,
chargeId: string,
staffId: string,
reason?: string,
): Promise<Freight.AdditionalCharge[]> {
const charge = await this.findOwned(bookingId, chargeId);
if (charge.status !== 'DRAFT' && charge.status !== 'SENT') {
throw new ConflictException('Only a draft or unpaid charge can be cancelled.');
}
if (charge.status === 'SENT' && charge.invoiceId) {
await this.billing.cancelInvoice(charge.invoiceId);
}
await this.repository.update(charge.id, {
status: 'CANCELLED',
cancelledByStaffId: staffId,
cancelledAt: new Date(),
cancelReason: reason ?? null,
});
return this.list(bookingId);
}
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
@OnEvent('additional_charge.invoice.paid')
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
const charge = await this.repository.findById(payload.sourceId);
if (!charge || charge.status === 'PAID') return;
await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() });
try {
const booking = await this.bookingsService.findById(charge.bookingId);
if (!booking.companyId) return;
const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.PAYMENT_RECEIVED,
title: 'Charge payment received',
body,
link: `/bookings/${charge.bookingId}`,
data: { bookingId: charge.bookingId, chargeId: charge.id },
});
await this.inbox.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.PAYMENT_RECEIVED,
title: 'Additional charge paid',
body,
link: `/bookings/${charge.bookingId}`,
data: { bookingId: charge.bookingId, chargeId: charge.id },
});
} catch (err) {
this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`);
}
}
private async toDtoList(rows: AdditionalCharge[]): Promise<Freight.AdditionalCharge[]> {
if (!rows.length) return [];
const filesByCharge = await this.filesService.findByResourceIdsGrouped(
rows.map((r) => r.id),
FILE_RESOURCE,
);
const names = await this.bookingsRepository.resolveStaffNames(
rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]),
);
const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id));
const invoices = invoiceIds.length
? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) })
: [];
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
return rows.map((r) => {
const file = filesByCharge.get(r.id)?.[0];
return {
id: r.id,
bookingId: r.bookingId,
reason: r.reason,
status: r.status,
amount: Number(r.amount),
currency: r.currency,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
invoiceId: r.invoiceId ?? null,
invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null,
paymentReference: r.paymentReference ?? null,
createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null,
createdAt: r.createdAt.toISOString(),
sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null,
sentAt: r.sentAt?.toISOString() ?? null,
paidAt: r.paidAt?.toISOString() ?? null,
cancelledAt: r.cancelledAt?.toISOString() ?? null,
cancelReason: r.cancelReason ?? null,
};
});
}
}

View File

@@ -42,6 +42,8 @@ import type { Response } from "express";
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { ClearanceEventService } from './clearance-event.service';
import { BillClearanceChargeDto } 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';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
@@ -176,6 +178,7 @@ export class BookingsController {
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly clearanceChargeService: BookingClearanceChargeService,
private readonly clearanceEventService: ClearanceEventService,
private readonly additionalChargeService: AdditionalChargeService,
) {}
@Post()
@@ -1210,6 +1213,64 @@ export class BookingsController {
);
}
// ── Additional charges (ad-hoc finance billing) ────────────────────────────
@Get(":id/additional-charges")
@MixedAudience(FREIGHT_PERMS.additionalCharges.view)
@ApiOperation({ summary: "Ad-hoc extra charges finance has raised against this booking" })
async getAdditionalCharges(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view);
if (!isStaff) {
const booking = await this.bookingsService.findById(id);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const charges = await this.additionalChargeService.list(id);
// A charge finance hasn't sent yet isn't the customer's to see.
return isStaff ? charges : charges.filter((c) => c.status !== "DRAFT");
}
@Post(":id/additional-charges")
@BookingStaff(FREIGHT_PERMS.additionalCharges.create)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Finance raises a new additional charge — draft, or send to the customer immediately",
})
createAdditionalCharge(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body() dto: CreateAdditionalChargeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.additionalChargeService.create(id, dto, resolveAuthUserId(user), file);
}
@Post(":id/additional-charges/:chargeId/send")
@BookingStaff(FREIGHT_PERMS.additionalCharges.send)
@ApiOperation({ summary: "Issue the draft charge's payable invoice and notify the customer" })
sendAdditionalCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: TCurrentUser,
) {
return this.additionalChargeService.send(id, chargeId, resolveAuthUserId(user));
}
@Post(":id/additional-charges/:chargeId/cancel")
@BookingStaff(FREIGHT_PERMS.additionalCharges.cancel)
@ApiOperation({ summary: "Withdraw a draft or unpaid additional charge" })
cancelAdditionalCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@Body() dto: CancelAdditionalChargeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.additionalChargeService.cancel(id, chargeId, resolveAuthUserId(user), dto.reason);
}
@Post(":id/clearance/output-documents")
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -38,6 +38,9 @@ import { BookingsService } from './bookings.service';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
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 { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
import { ClearanceEventService } from './clearance-event.service';
@@ -82,6 +85,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ConsolidationApproval,
BookingClearanceCharge,
BookingClearanceEvent,
AdditionalCharge,
]),
BillingModule,
DocumentsModule,
@@ -119,6 +123,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingInvoiceService,
BookingClearanceChargeService,
ClearanceEventService,
AdditionalChargeRepository,
AdditionalChargeService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,

View File

@@ -0,0 +1,35 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsPositive, IsString, Length } from 'class-validator';
export class CreateAdditionalChargeDto {
@ApiProperty({ example: 'Re-weighing fee at Mojo dry port' })
@IsString()
@Length(1, 2000)
reason!: string;
@ApiProperty({ example: 4500 })
@Type(() => Number)
@IsNumber()
@IsPositive()
amount!: number;
@ApiProperty({ example: 'ETB' })
@IsString()
@Length(3, 8)
currency!: string;
/** 'send' issues the invoice + notifies the customer immediately; omit/'draft' just saves it. */
@ApiPropertyOptional({ enum: ['draft', 'send'], default: 'draft' })
@IsOptional()
@IsIn(['draft', 'send'])
action?: 'draft' | 'send';
}
export class CancelAdditionalChargeDto {
@ApiPropertyOptional({ example: 'Raised in error' })
@IsOptional()
@IsString()
@Length(1, 2000)
reason?: string;
}

View File

@@ -0,0 +1,74 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const ADDITIONAL_CHARGE_STATUSES = [
'DRAFT',
'SENT',
'PAID',
'CANCELLED',
] as const;
export type AdditionalChargeStatus = (typeof ADDITIONAL_CHARGE_STATUSES)[number];
/**
* An ad-hoc extra charge finance raises against a booking — free-text reason,
* any number per booking (unlike `BookingClearanceCharge`, which caps at one
* per type). DRAFT until finance sends it; sending issues the payable invoice
* and notifies the customer (in-app + SMS + email). PAID via the standard
* `additional_charge.invoice.paid` settlement event.
*/
@Entity({ schema: 'freight', name: 'additional_charge' })
@Index(['bookingId'])
export class AdditionalCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'reason', type: 'text' })
reason!: string;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: AdditionalChargeStatus;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2 })
amount!: string;
@Column({ name: 'currency', type: 'varchar', length: 8 })
currency!: string;
/** The supporting attachment (FileRecord), if any. */
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null;
/** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;
/** CBE bill reference / PNR the customer pays against, once issued. */
@Column({ name: 'payment_reference', type: 'varchar', length: 64, nullable: true })
paymentReference?: string | null;
@Column({ name: 'created_by_staff_id', type: 'uuid', nullable: true })
createdByStaffId?: string | null;
@Column({ name: 'sent_by_staff_id', type: 'uuid', nullable: true })
sentByStaffId?: string | null;
@Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
sentAt?: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
@Column({ name: 'cancelled_by_staff_id', type: 'uuid', nullable: true })
cancelledByStaffId?: string | null;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt?: Date | null;
@Column({ name: 'cancel_reason', type: 'text', nullable: true })
cancelReason?: string | null;
}

View File

@@ -1330,6 +1330,30 @@ export const PORT_TERMINAL_PERMISSIONS: FreightPermissionSeed[] = [
),
];
// E. Additional charges — ad-hoc finance charges raised against a booking
export const ADDITIONAL_CHARGE_PERMISSIONS: FreightPermissionSeed[] = [
perm(
"f2e00001-0001-4000-8000-000000000001",
"edr_freight_app:additional_charges:view",
"View additional charges",
),
perm(
"f2e00001-0001-4000-8000-000000000002",
"edr_freight_app:additional_charges:create",
"Create additional charge",
),
perm(
"f2e00001-0001-4000-8000-000000000003",
"edr_freight_app:additional_charges:send",
"Send additional charge to customer",
),
perm(
"f2e00001-0001-4000-8000-000000000004",
"edr_freight_app:additional_charges:cancel",
"Cancel additional charge",
),
];
// E'. Train-scheduling finer actions (augment existing view/manage)
export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
perm(
@@ -1713,6 +1737,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:warehouse_fee_invoices:get_notification",
"Receive warehouse fee accrual notifications",
),
perm(
"f3a00001-0001-4000-8000-000000000009",
"edr_freight_app:additional_charges:get_notification",
"Receive additional charge notifications",
),
];
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
@@ -1726,6 +1755,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...FLEET_ROAD_PERMISSIONS,
...WAREHOUSE_PERMISSIONS,
...PORT_TERMINAL_PERMISSIONS,
...ADDITIONAL_CHARGE_PERMISSIONS,
...SCHEDULING_EXTRA_PERMISSIONS,
...CONFIG_SETTINGS_PERMISSIONS,
...STAFF_IAM_PERMISSIONS,
@@ -2170,6 +2200,14 @@ export const FREIGHT_PERMS = {
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:warehouse_fee_invoices:get_notification",
},
additionalCharges: {
view: "edr_freight_app:additional_charges:view",
create: "edr_freight_app:additional_charges:create",
send: "edr_freight_app:additional_charges:send",
cancel: "edr_freight_app:additional_charges:cancel",
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:additional_charges:get_notification",
},
settings: {
fileUpload: {
view: "edr_freight_app:settings:file_upload:view",
@@ -2345,6 +2383,9 @@ export const NOTIFICATION_PERMISSION_ANCHORS: Record<string, string[]> = {
[FREIGHT_PERMS.warehouseFeeInvoices.getNotification]: [
FREIGHT_PERMS.warehouseFeeInvoices.view,
],
[FREIGHT_PERMS.additionalCharges.getNotification]: [
FREIGHT_PERMS.additionalCharges.view,
],
};
/** Both arms of a freight-type-split permission (for one-of route guards). */
@@ -2551,6 +2592,12 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.shippingLineCredits.invoice,
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
// Additional Payments: Finance is the only role that raises and sends
// ad-hoc charges to a customer.
FREIGHT_PERMS.additionalCharges.view,
FREIGHT_PERMS.additionalCharges.create,
FREIGHT_PERMS.additionalCharges.send,
FREIGHT_PERMS.additionalCharges.cancel,
],
// Global Logistics: manages ONLY the customs-clearance queue. Scoped out of
// the general booking-request list (no bookings:view) — instead a dedicated

View File

@@ -0,0 +1,397 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
FileButton,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import {
Ban,
Download,
Eye,
FileText,
Plus,
Receipt,
Send,
Upload,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"];
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
DRAFT: { label: "Draft", color: "gray" },
SENT: { label: "Sent — unpaid", color: "orange" },
PAID: { label: "Paid", color: "edr-green" },
CANCELLED: { label: "Cancelled", color: "red" },
};
export interface AdditionalPaymentsTabProps {
bookingId: string;
onViewFile: (file: { name: string; url: string }) => void;
}
/**
* Ad-hoc extra charges finance raises against a booking — any number, free-text
* reason. Draft until sent; sending issues the payable invoice and notifies the
* customer (in-app + SMS + email). Settles the same way every invoice does.
*/
export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPaymentsTabProps) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const { data: charges, isLoading } = useQuery({
queryKey: ["additional-charges", bookingId],
queryFn: () => bookingsService.getAdditionalCharges(bookingId),
});
const refresh = (next: Freight.AdditionalCharge[]) =>
qc.setQueryData(["additional-charges", bookingId], next);
const onError = (e: unknown) =>
toast.error(extractErrorMessage(e, "Could not update the charge"));
const create = useMutation({
mutationFn: (p: {
reason: string;
amount: number;
currency: string;
action: "draft" | "send";
file?: File | null;
}) => bookingsService.createAdditionalCharge(bookingId, p),
onSuccess: (next, p) => {
toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved");
refresh(next);
setModalOpen(false);
},
onError,
});
const send = useMutation({
mutationFn: (chargeId: string) => bookingsService.sendAdditionalCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Charge sent to the customer");
refresh(next);
},
onError,
});
const cancel = useMutation({
mutationFn: (chargeId: string) => bookingsService.cancelAdditionalCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Charge cancelled");
refresh(next);
},
onError,
});
if (isLoading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading additional charges</Text>
</Group>
);
}
const rows = charges ?? [];
const busy = send.isPending || cancel.isPending;
return (
<Stack gap="md" maw={860}>
<Group justify="space-between">
<Text fz="13px" fw={700} c="edr-text">
Additional charges
</Text>
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Plus size={14} />}
onClick={() => setModalOpen(true)}
>
Add charge
</Button>
</Group>
{rows.length === 0 && (
<Text fz="12.5px" c="dimmed">
No additional charges raised on this booking yet.
</Text>
)}
{rows.map((charge) => (
<ChargeCard
key={charge.id}
charge={charge}
busy={busy}
onViewFile={onViewFile}
onSend={() => send.mutate(charge.id)}
onCancel={() => cancel.mutate(charge.id)}
/>
))}
<AddChargeModal
opened={modalOpen}
onClose={() => setModalOpen(false)}
busy={create.isPending}
onSubmit={(p) => create.mutate(p)}
/>
</Stack>
);
}
function ChargeCard({
charge,
busy,
onViewFile,
onSend,
onCancel,
}: {
charge: Freight.AdditionalCharge;
busy: boolean;
onViewFile: (file: { name: string; url: string }) => void;
onSend: () => void;
onCancel: () => void;
}) {
const meta = STATUS_META[charge.status];
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap={10} wrap="nowrap" align="flex-start">
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
<Box>
<Text fz="14px" fw={700} c="edr-text">
{charge.reason}
</Text>
<Text fz="11.5px" c="dimmed">
Raised{charge.createdByName ? ` by ${charge.createdByName}` : ""} ·{" "}
{formatDateTime(charge.createdAt)}
</Text>
{charge.sentAt && (
<Text fz="11.5px" c="dimmed">
Sent{charge.sentByName ? ` by ${charge.sentByName}` : ""} ·{" "}
{formatDateTime(charge.sentAt)}
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
</Text>
)}
{charge.paidAt && (
<Text fz="11.5px" c="edr-green.8" fw={600}>
Paid · {formatDateTime(charge.paidAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
</Text>
)}
{charge.cancelledAt && (
<Text fz="11.5px" c="red.7">
Cancelled · {formatDateTime(charge.cancelledAt)}
{charge.cancelReason ? `${charge.cancelReason}` : ""}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
</Text>
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
</Group>
</Group>
{charge.file && (
<Group gap={8} mt="sm" wrap="nowrap">
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0 }}>
{charge.file.name}
</Text>
{isViewable({ name: charge.file.name, url: "" }) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
void fetchViewableFile(charge.file!.id, charge.file!.name).then(onViewFile)
}
c="edr-green"
style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box
component="button"
type="button"
onClick={() => void downloadBookingFile(charge.file!.id, charge.file!.name)}
c="edr-green"
style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }}
>
<Download size={15} />
</Box>
</Tooltip>
</Group>
)}
{(charge.status === "DRAFT" || charge.status === "SENT") && (
<Group mt="sm" gap={8} justify="flex-end">
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<Ban size={14} />}
disabled={busy}
onClick={onCancel}
>
Cancel
</Button>
{charge.status === "DRAFT" && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Send size={14} />}
disabled={busy}
onClick={onSend}
>
Send to customer
</Button>
)}
</Group>
)}
</Paper>
);
}
function AddChargeModal({
opened,
onClose,
busy,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
busy: boolean;
onSubmit: (p: {
reason: string;
amount: number;
currency: string;
action: "draft" | "send";
file?: File | null;
}) => void;
}) {
const [reason, setReason] = useState("");
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [file, setFile] = useState<File | null>(null);
const valid = reason.trim().length > 0 && Number(amount) > 0;
const reset = () => {
setReason("");
setAmount("");
setCurrency("ETB");
setFile(null);
};
const submit = (action: "draft" | "send") => {
if (!valid) return;
onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file });
};
return (
<Modal
opened={opened}
onClose={() => {
onClose();
reset();
}}
title="Add additional charge"
radius="md"
centered
>
<Stack gap="sm">
<Textarea
label="Reason for charge"
placeholder="e.g. Re-weighing fee at Mojo dry port"
autosize
minRows={2}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Group gap={8} align="flex-end">
<NumberInput
label="Amount"
min={0.01}
decimalScale={2}
value={amount}
onChange={setAmount}
style={{ flex: 1 }}
/>
<Select
label="Currency"
data={CURRENCIES}
value={currency}
onChange={(v) => v && setCurrency(v)}
w={100}
/>
</Group>
<FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => (
<Button
{...props}
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Attach a document (optional)"}
</Button>
)}
</FileButton>
<Group justify="flex-end" mt="sm" gap={8}>
<Button
variant="light"
color="gray"
radius="md"
disabled={busy || !valid}
loading={busy}
onClick={() => submit("draft")}
>
Save draft
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Send size={14} />}
disabled={busy || !valid}
loading={busy}
onClick={() => submit("send")}
>
Send to customer
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,10 +1,11 @@
import { useNavigate } from "react-router-dom";
import { ExternalLink, MoreHorizontal } from "lucide-react";
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import {
isAllocateAction,
isClearanceNavAction,
@@ -50,6 +51,14 @@ export function BookingActionsMenu({
const goToClearanceTab = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
const goToAdditionalCharges = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=additional-charges`);
const canSeeAdditionalCharges = hasFreightPermission(
user,
FREIGHT_PERMS.additionalCharges.view,
);
const handleAction = (action: (typeof actions)[number]) => {
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
@@ -144,6 +153,17 @@ export function BookingActionsMenu({
);
})}
{actions.length > 0 && <Menu.Divider />}
{canSeeAdditionalCharges && (
<Menu.Item
leftSection={<Receipt size={15} />}
onClick={() => {
onSuppressRowClick?.();
goToAdditionalCharges();
}}
>
Additional charges
</Menu.Item>
)}
<Menu.Item
leftSection={<ExternalLink size={15} />}
onClick={() => {

View File

@@ -340,6 +340,12 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
additionalCharges: {
view: "edr_freight_app:additional_charges:view",
create: "edr_freight_app:additional_charges:create",
send: "edr_freight_app:additional_charges:send",
cancel: "edr_freight_app:additional_charges:cancel",
},
/**
* Audit trail. View-only — the API exposes no write routes for audit rows,
* so there is no manage/delete counterpart to grant.

View File

@@ -13,6 +13,7 @@ import {
Milestone,
MoreHorizontal,
Package,
Receipt,
RefreshCw,
Ship,
Truck,
@@ -64,6 +65,7 @@ import {
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsTab";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { formatDateTime, formatMoney } from "@/lib/format";
@@ -74,7 +76,10 @@ import {
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -82,6 +87,12 @@ export default function BookingRequestDetailPage() {
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const canSeeAdditionalCharges = hasFreightPermission(
user,
FREIGHT_PERMS.additionalCharges.view,
);
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
// other half of the shared wagon. Everything below — KPIs, stepper, the
@@ -206,6 +217,8 @@ export default function BookingRequestDetailPage() {
? "documents"
: requestedTab === "trucks"
? "trucks"
: requestedTab === "additional-charges"
? "additional-charges"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
@@ -509,6 +522,14 @@ export default function BookingRequestDetailPage() {
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
Trucks
</Tabs.Tab>
{canSeeAdditionalCharges && (
<Tabs.Tab
value="additional-charges"
leftSection={<Receipt size={16} />}
>
Additional payments
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="overview">
@@ -528,6 +549,11 @@ export default function BookingRequestDetailPage() {
<Tabs.Panel value="trucks">
<BookingTrucksPanel bookingId={booking.id} />
</Tabs.Panel>
{canSeeAdditionalCharges && (
<Tabs.Panel value="additional-charges">
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />
</Tabs.Panel>
)}
</Tabs>
</Grid.Col>
@@ -562,6 +588,7 @@ export default function BookingRequestDetailPage() {
</Grid.Col>
</Grid>
</Stack>
{viewer}
</PageContainer>
);
}

View File

@@ -508,6 +508,53 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
// ── Additional charges (ad-hoc finance billing) ──
getAdditionalCharges: async (id: string): Promise<Freight.AdditionalCharge[]> => {
const response = await client.get(`/bookings/${id}/additional-charges`);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */
createAdditionalCharge: async (
id: string,
payload: { reason: string; amount: number; currency: string; action: "draft" | "send"; file?: File | null },
): Promise<Freight.AdditionalCharge[]> => {
const form = new FormData();
form.append("reason", payload.reason);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
form.append("action", payload.action);
if (payload.file) form.append("file", payload.file);
const response = await client.post(`/bookings/${id}/additional-charges`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Issues the draft charge's payable invoice and notifies the customer. */
sendAdditionalCharge: async (
id: string,
chargeId: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.post(
`/bookings/${id}/additional-charges/${chargeId}/send`,
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Withdraws a draft or unpaid additional charge. */
cancelAdditionalCharge: async (
id: string,
chargeId: string,
reason?: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.post(
`/bookings/${id}/additional-charges/${chargeId}/cancel`,
{ reason },
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
requestTransitAssignee: (id: string, note?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {

View File

@@ -39,6 +39,7 @@ import {
ConsolidationWaitingBanner,
} from "./components/Notices";
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
import { AdditionalChargesPanel } from "./components/AdditionalChargesPanel";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { ScheduleCard } from "./components/ScheduleCard";
@@ -326,6 +327,7 @@ export function ReadonlyBookingView({
paying={pay.processing}
showCountdown={showCountdown}
/>
<AdditionalChargesPanel bookingId={booking.id} />
<ScheduleCard
booking={booking}
title="Consignment & Schedule"

View File

@@ -0,0 +1,129 @@
import { useState } from "react";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { CreditCard } from "lucide-react";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { PaymentMethodModal } from "./PaymentMethodModal";
import { CardTitle, SectionCard } from "./layout";
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
DRAFT: { label: "Draft", color: "gray" },
SENT: { label: "Awaiting payment", color: "#B07D14" },
PAID: { label: "Paid", color: "#0A6F4D" },
CANCELLED: { label: "Cancelled", color: "red" },
};
/**
* Ad-hoc extra charges EDR has raised against this booking — separate from the
* freight invoice on `BookingPaymentPanel`. Only ever shows charges already
* SENT (or settled) — a DRAFT charge isn't visible to the customer yet.
*/
export function AdditionalChargesPanel({ bookingId }: { bookingId: string }) {
const { data: charges = [] } = useQuery({
queryKey: ["additional-charges", bookingId],
queryFn: () => bookingsService.getAdditionalCharges(bookingId),
});
const visible = charges.filter((c) => c.status !== "DRAFT");
if (visible.length === 0) return null;
return (
<SectionCard p={22}>
<CardTitle>Additional charges</CardTitle>
<Stack gap={12} mt={12}>
{visible.map((charge) => (
<ChargeRow key={charge.id} charge={charge} />
))}
</Stack>
</SectionCard>
);
}
function ChargeRow({ charge }: { charge: Freight.AdditionalCharge }) {
const meta = STATUS_META[charge.status];
return (
<Box
p={14}
style={{ borderRadius: 10, border: "1px solid #EEF2F6" }}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={700} c="#10202F">
{charge.reason}
</Text>
<Text fz="12px" c="#9AA8B5" mt={2}>
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
</Text>
</Box>
<Badge
radius="sm"
variant="light"
styles={{ root: { backgroundColor: `${meta.color}22`, color: meta.color } }}
>
{meta.label}
</Badge>
</Group>
{charge.status === "SENT" && charge.invoiceId && (
<ChargePayButton invoiceId={charge.invoiceId} amount={charge.amount} currency={charge.currency} />
)}
</Box>
);
}
function ChargePayButton({
invoiceId,
amount,
currency,
}: {
invoiceId: string;
amount: number;
currency: string;
}) {
const [modalOpen, setModalOpen] = useState(false);
const flow = useInvoicePayment();
const close = () => {
if (!flow.processing) {
setModalOpen(false);
flow.reset();
}
};
return (
<ModalSafeWrapper>
<Button
mt={10}
size="xs"
radius="md"
fw={700}
color="edr-green"
leftSection={<CreditCard size={14} />}
onClick={(e) => {
e.stopPropagation();
setModalOpen(true);
}}
>
Pay now
</Button>
<PaymentMethodModal
opened={modalOpen}
onClose={close}
amountLabel={`${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
currency={currency}
processing={flow.processing}
error={flow.error}
otp={flow.otp}
bill={flow.bill}
onConfirm={(method, payerAccount) => flow.pay(invoiceId, method, payerAccount)}
/>
</ModalSafeWrapper>
);
}

View File

@@ -324,6 +324,11 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}/mile-summary`);
return data.data;
},
/** Ad-hoc extra charges finance has raised against this booking. */
getAdditionalCharges: async (id: string): Promise<Freight.AdditionalCharge[]> => {
const { data } = await client.get(`/api/bookings/${id}/additional-charges`);
return data.data;
},
assignCustomerTruck: async (
id: string,
payload: CustomerTruckAssignmentPayload,

View File

@@ -200,6 +200,11 @@ export enum InvoiceSource {
* per-booking link.
*/
ShippingLineCredit = "shipping_line_credit",
/**
* Ad-hoc extra charge finance raises against a booking (e.g. a fee not
* covered by an existing fee rule). `sourceId` is the charge id.
*/
AdditionalCharge = "additional_charge",
}
export enum SchedulingStatus {
@@ -865,6 +870,36 @@ export interface ClearanceDocRequest {
at: string;
}
// ── Additional charges (ad-hoc finance billing) ──────────────────────────────
/**
* DRAFT: staff is still editing, nothing sent. SENT: invoice issued to the
* customer (in-app + SMS + email). PAID: the invoice settled. CANCELLED:
* withdrawn before payment.
*/
export type AdditionalChargeStatus = "DRAFT" | "SENT" | "PAID" | "CANCELLED";
/** One ad-hoc extra charge finance raised against a booking. */
export interface AdditionalCharge {
id: string;
bookingId: string;
reason: string;
status: AdditionalChargeStatus;
amount: number;
currency: string;
file: { id: string; name: string; url: string } | null;
invoiceId: string | null;
invoiceNumber: string | null;
paymentReference: string | null;
createdByName: string | null;
createdAt: string;
sentByName: string | null;
sentAt: string | null;
paidAt: string | null;
cancelledAt: string | null;
cancelReason: string | null;
}
/** One entry of a clearance document's audit trail, oldest first. */
export interface ClearanceDocumentEvent {
type: "UPLOADED" | "RESUBMITTED" | "QUERIED" | "APPROVED";