feat: implement shipping line bookings management

- Add ShippingLineBookingsPage for listing and managing shipping line bookings.
- Create ShippingLineDocumentsModal for document uploads related to bookings.
- Introduce ShippingLineInitiateModal for initiating new shipping line bookings.
- Implement booking document state management with booking-doc-state utility.
- Add shipping line bookings service for API interactions.
- Update index to export new components and services.
- Enhance types for freight to include shipping line credits.
This commit is contained in:
marshalyordanos
2026-08-13 15:54:40 +03:00
parent 9aae132dd4
commit 9fff469ffa
50 changed files with 4485 additions and 77 deletions

View File

@@ -19,7 +19,8 @@ import { FilesModule } from "../files/files.module";
imports: [
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
// Cycles back via ShippingLineCompaniesModule, which imports this module.
forwardRef(() => CompaniesModule),
DocumentsModule,
UserTradeAccessModule,
FilesModule,
@@ -29,3 +30,4 @@ import { FilesModule } from "../files/files.module";
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -112,8 +112,16 @@ export interface GenerateInvoiceInput {
sourceId: string;
/** What the invoice is for (e.g. "prepaid", "credit"). */
type: string;
companyId: string;
companyProfileId: string;
/** The customer billed. Omit only when billing a shipping line instead. */
companyId?: string | null;
companyProfileId?: string | null;
/**
* The shipping line billed, for an invoice covering batched shipping-line
* credits. Mutually exclusive with `companyId` — the DB enforces this via
* `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload
* setting both or neither before it ever reaches the constraint.
*/
shippingLineCompanyId?: string | null;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
@@ -139,8 +147,11 @@ export interface InvoiceEventPayload {
source: Freight.InvoiceSource;
sourceId: string;
type: string;
companyId: string;
companyProfileId: string;
/** Null when the payer is a shipping line rather than a customer company. */
companyId: string | null;
companyProfileId: string | null;
/** Set only on shipping-line invoices; mutually exclusive with `companyId`. */
shippingLineCompanyId?: string | null;
totalAmount: number;
currency: string;
status: Freight.InvoiceStatus;
@@ -609,7 +620,6 @@ export class BillingService {
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
console.log("oooooooooo", input);
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
@@ -622,6 +632,21 @@ export class BillingService {
const status = input.status ?? Freight.InvoiceStatus.Pending;
const issued = status !== Freight.InvoiceStatus.Draft;
// Exactly one payer, checked here so a bad payload fails with a clear
// message instead of a raw `chk_invoices_single_payer` violation.
const billsCompany = Boolean(input.companyId);
const billsShippingLine = Boolean(input.shippingLineCompanyId);
if (billsCompany === billsShippingLine) {
throw new BadRequestException(
"An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.",
);
}
if (billsCompany && !input.companyProfileId) {
throw new BadRequestException(
"companyProfileId is required when billing a company.",
);
}
const lines = input.lines.map((l) => {
const quantity = l.quantity ?? 1;
const unitRate = l.unitRate ?? 0;
@@ -657,8 +682,9 @@ export class BillingService {
source: input.source,
sourceId: input.sourceId,
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
companyId: input.companyId ?? null,
companyProfileId: input.companyProfileId ?? null,
shippingLineCompanyId: input.shippingLineCompanyId ?? null,
subtotalAmount: round2(subtotalAmount),
taxAmount: round2(taxAmount),
totalAmount: round2(totalAmount),
@@ -988,6 +1014,7 @@ export class BillingService {
type: invoice.type,
companyId: invoice.companyId,
companyProfileId: invoice.companyProfileId,
shippingLineCompanyId: invoice.shippingLineCompanyId ?? null,
totalAmount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,

View File

@@ -23,22 +23,37 @@ export class Invoice extends BaseEntity {
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
invoiceNumber!: string;
/** The customer (company) this invoice is billed to. */
@Column({ name: "company_id", type: "uuid" })
companyId!: string;
/**
* The customer (company) this invoice is billed to. Null on a shipping-line
* invoice, which is billed to `shippingLineCompanyId` instead — a shipping
* line is deliberately not a `companies` row. A DB CHECK
* (`chk_invoices_single_payer`) guarantees exactly one of the two is set.
*/
@Column({ name: "company_id", type: "uuid", nullable: true })
companyId!: string | null;
@ManyToOne(() => Company)
@JoinColumn({ name: "company_id" })
company?: Company;
/** The specific company profile (importer/exporter/forwarder/...) billed. */
@Column({ name: "company_profile_id", type: "uuid" })
companyProfileId!: string;
@Column({ name: "company_profile_id", type: "uuid", nullable: true })
companyProfileId!: string | null;
@ManyToOne(() => CompanyProfile)
@JoinColumn({ name: "company_profile_id" })
companyProfile?: CompanyProfile;
/**
* The shipping line billed, when this invoice bills batched shipping-line
* credits rather than a customer booking. Mutually exclusive with
* `companyId`. No relation is declared: `ShippingLineCredit` already owns
* that edge, and importing the shipping-lines module here would close an
* import cycle (shipping-lines already depends on billing).
*/
@Column({ name: "shipping_line_company_id", type: "uuid", nullable: true })
shippingLineCompanyId?: string | null;
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;