mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
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:
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
/** Payload for a shipping line cancelling its own booking. */
|
||||
export class CancelShippingLineBookingDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
"Why the booking is being cancelled. Recorded on the booking's review-note log.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
import { FREIGHT_TYPES } from "../../bookings/entities/booking.entity";
|
||||
|
||||
/**
|
||||
* Payload for initiating a bare shipping-line booking.
|
||||
*
|
||||
* A customer's bare booking inherits its lane from the contract it is initiated
|
||||
* under. Shipping lines have no contract, so the lane comes from a route the
|
||||
* caller picks — one choice that yields origin, destination and trade direction
|
||||
* together, rather than three fields that can contradict each other.
|
||||
*/
|
||||
export class InitiateShippingLineBookingDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The lane being booked. Supplies the booking's origin yard, destination yard and trade direction.",
|
||||
})
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: "Service type being booked.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
serviceTypeId?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
enum: FREIGHT_TYPES,
|
||||
description: "Freight type. Defaults to CONTAINER.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(FREIGHT_TYPES)
|
||||
freightType?: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
|
||||
/** Finance's request to bill a batch of unbilled credits as one invoice. */
|
||||
export class GenerateCreditInvoiceDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The unbilled credits to bill. All must belong to the same shipping line and share one currency.",
|
||||
type: [String],
|
||||
format: "uuid",
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID("4", { each: true })
|
||||
creditIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Pay window in days from issue. Defaults to the standard invoice term.",
|
||||
minimum: 1,
|
||||
example: 14,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
dueInDays?: number;
|
||||
}
|
||||
|
||||
/** Write-off of a single unbilled credit. */
|
||||
export class CancelCreditDto {
|
||||
@ApiProperty({
|
||||
description: "Why the credit is being written off. Recorded on the row.",
|
||||
example: "Booking voided before departure",
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(255)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Booking } from "../../bookings/entities/booking.entity";
|
||||
import { Invoice } from "../../billing/entities/invoice.entity";
|
||||
import { ShippingLineCompany } from "./shipping-line-company.entity";
|
||||
|
||||
/** Where a credit sits between "service used" and "money received". */
|
||||
export enum ShippingLineCreditStatus {
|
||||
/** Service used, priced, not yet on any invoice. Counts as debt. */
|
||||
Unbilled = "UNBILLED",
|
||||
/** Finance put it on an invoice; awaiting payment. Still counts as debt. */
|
||||
Billed = "BILLED",
|
||||
/** The invoice settled. Terminal — no longer debt, and never re-billed. */
|
||||
Paid = "PAID",
|
||||
/** Written off / booking voided. Terminal, excluded from every total. */
|
||||
Cancelled = "CANCELLED",
|
||||
}
|
||||
|
||||
/** Statuses a shipping line still owes money for. */
|
||||
export const OUTSTANDING_CREDIT_STATUSES = [
|
||||
ShippingLineCreditStatus.Unbilled,
|
||||
ShippingLineCreditStatus.Billed,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* What a shipping line owes for one booking.
|
||||
*
|
||||
* Shipping lines get the service first and pay later, so a booking of theirs
|
||||
* raises no invoice and passes no payment gate — it raises one of these. The
|
||||
* amount is frozen when the booking is priced and is never recalculated, so a
|
||||
* later rate change cannot silently alter a debt already incurred.
|
||||
*
|
||||
* Finance batches unbilled credits into one invoice (see
|
||||
* `ShippingLineCreditsService.generateInvoice`); the line pays that invoice
|
||||
* through the ordinary CBE flow; settlement flips the batch to PAID and the
|
||||
* debt disappears. The outstanding figure is always derived by summing
|
||||
* {@link OUTSTANDING_CREDIT_STATUSES} rows — there is no balance column,
|
||||
* because a stored balance is one missed UPDATE away from being a lie.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "shipping_line_credits" })
|
||||
@Index(["shippingLineCompanyId", "status"])
|
||||
@Index(["invoiceId"])
|
||||
export class ShippingLineCredit extends BaseEntity {
|
||||
/** The line that owes this. */
|
||||
@Column({ name: "shipping_line_company_id", type: "uuid" })
|
||||
shippingLineCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => ShippingLineCompany)
|
||||
@JoinColumn({ name: "shipping_line_company_id" })
|
||||
shippingLineCompany?: ShippingLineCompany;
|
||||
|
||||
/**
|
||||
* The booking that incurred the charge. Unique among live rows (partial
|
||||
* index excludes soft-deleted and CANCELLED), so one booking can never be
|
||||
* billed twice.
|
||||
*/
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "booking_id" })
|
||||
booking?: Booking;
|
||||
|
||||
/** Frozen at pricing time. Never recalculated. */
|
||||
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
|
||||
amount!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ShippingLineCreditStatus,
|
||||
default: ShippingLineCreditStatus.Unbilled,
|
||||
})
|
||||
status!: ShippingLineCreditStatus;
|
||||
|
||||
/** What the charge is for; becomes the invoice line description. */
|
||||
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
/** The invoice this credit was billed on; null while UNBILLED. */
|
||||
@Column({ name: "invoice_id", type: "uuid", nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
@ManyToOne(() => Invoice)
|
||||
@JoinColumn({ name: "invoice_id" })
|
||||
invoice?: Invoice;
|
||||
|
||||
/** When finance put it on an invoice. */
|
||||
@Column({ name: "billed_at", type: "timestamptz", nullable: true })
|
||||
billedAt?: Date | null;
|
||||
|
||||
/** When that invoice settled. */
|
||||
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
@Column({ name: "cancelled_at", type: "timestamptz", nullable: true })
|
||||
cancelledAt?: Date | null;
|
||||
|
||||
@Column({
|
||||
name: "cancellation_reason",
|
||||
type: "varchar",
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
cancellationReason?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PortalCustomer } from "../../common/booking-guards";
|
||||
import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto";
|
||||
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
|
||||
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings a shipping line makes for itself, from the portal.
|
||||
*
|
||||
* Separate from `/bookings` (customers) on purpose — see
|
||||
* {@link ShippingLineBookingsService} for why the two flows are not merged.
|
||||
* `PortalCustomer` only proves a valid portal session; the service resolves the
|
||||
* shipping-line account from that session and rejects anyone else, so the owner
|
||||
* is never taken from the request body.
|
||||
*/
|
||||
@ApiTags("shipping-line-bookings")
|
||||
@Controller("shipping-line-bookings")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineBookingsController {
|
||||
constructor(
|
||||
private readonly shippingLineBookingsService: ShippingLineBookingsService,
|
||||
) {}
|
||||
|
||||
@Post("initiate")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.",
|
||||
})
|
||||
async initiate(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: InitiateShippingLineBookingDto,
|
||||
) {
|
||||
return this.shippingLineBookingsService.initiate(user.id, dto);
|
||||
}
|
||||
|
||||
// Declared before @Get(":id") so the path isn't captured as a booking id.
|
||||
@Get("reference-data")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Catalog for the initiate form: bookable routes (each carrying its trade direction) and service types.",
|
||||
})
|
||||
async referenceData(@CurrentUser() user: CurrentIamUser) {
|
||||
return this.shippingLineBookingsService.referenceData(user.id);
|
||||
}
|
||||
|
||||
@Get("my")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "List the signed-in shipping line's bookings." })
|
||||
async listMine(@CurrentUser() user: CurrentIamUser) {
|
||||
return this.shippingLineBookingsService.listMine(user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Get one of the signed-in shipping line's bookings." })
|
||||
async findMine(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.shippingLineBookingsService.findMine(user.id, id);
|
||||
}
|
||||
|
||||
@Post(":id/cancel")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.",
|
||||
})
|
||||
async cancelMine(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelShippingLineBookingDto,
|
||||
) {
|
||||
return this.shippingLineBookingsService.cancelMine(
|
||||
user.id,
|
||||
id,
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { insertWithGeneratedReference } from "@edr/api-common";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
|
||||
import { BookingDocumentReview } from "../bookings/entities/booking-document-review.entity";
|
||||
import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { formatRouteLabel, Route } from "../routes/entities/route.entity";
|
||||
import { ServiceType } from "../rule-engine/entities/service-type.entity";
|
||||
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/**
|
||||
* The only trade direction a shipping line books.
|
||||
*
|
||||
* Their cargo arrives by sea at Djibouti and moves inland to Ethiopia, which is
|
||||
* IMPORT by the rule routes are stamped with (DJ→ET = IMPORT, ET→DJ = EXPORT,
|
||||
* same country = DOMESTIC). Export and intercity lanes are therefore neither
|
||||
* offered nor accepted.
|
||||
*/
|
||||
const SHIPPING_LINE_DIRECTION = "IMPORT";
|
||||
|
||||
/**
|
||||
* Statuses a shipping line may cancel its own booking from — everything before
|
||||
* the booking is priced. Past this point cancelling has billing consequences
|
||||
* (fees, credit notes) and belongs with Operations.
|
||||
*/
|
||||
const SHIPPING_LINE_CANCELLABLE_STATUSES: string[] = [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
"CHANGES_REQUESTED",
|
||||
];
|
||||
|
||||
/**
|
||||
* Booking creation for shipping lines.
|
||||
*
|
||||
* Deliberately separate from `BookingsService` / `ContractBookingService`
|
||||
* rather than a branch inside them. Those are built end to end around a
|
||||
* customer: a `companies` row, an approved operational `company_profile`, a
|
||||
* contract supplying route/quantities, and contract-capacity accounting. A
|
||||
* shipping line has none of that — it books directly, without a contract — so
|
||||
* branching there would mean threading "no company, no profile, no contract"
|
||||
* through every method a customer booking passes through. Keeping it here means
|
||||
* the customer paths are not touched at all.
|
||||
*
|
||||
* What IS shared is the table and the downstream lifecycle: the row lands in
|
||||
* `freight.bookings` at `AWAITING_DOCUMENTS`, the shipping line uploads its
|
||||
* documents against the `shipping_line_booking_documents` file-upload setting,
|
||||
* and Operations reviews and finalizes them through the same clearance flow
|
||||
* customers already use.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShippingLineBookingsService {
|
||||
constructor(
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookingsRepository: Repository<Booking>,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the shipping-line account for a signed-in user, or reject. Every
|
||||
* entry point goes through this: the owner is taken from the session, never
|
||||
* from the request body, so one shipping line cannot book as another.
|
||||
*/
|
||||
private async requireShippingLine(userId: string) {
|
||||
const shippingLine =
|
||||
await this.shippingLineCompaniesService.findByUserId(userId);
|
||||
if (!shippingLine) {
|
||||
throw new ForbiddenException("This account is not a shipping line.");
|
||||
}
|
||||
if (shippingLine.status !== "active") {
|
||||
throw new ForbiddenException(
|
||||
"This shipping-line account is suspended and cannot create bookings.",
|
||||
);
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog the initiate form needs: the lanes EDR actually runs, and the
|
||||
* services that can be booked on their own.
|
||||
*
|
||||
* Routes are offered instead of two loose yard pickers so a shipping line
|
||||
* cannot invent a lane that does not exist — and because the route already
|
||||
* carries its trade direction, which is otherwise guesswork.
|
||||
*
|
||||
* Read-only and scoped to bookable rows, which is why it lives here rather
|
||||
* than reusing the staff `/routes` controller (gated behind fleet
|
||||
* permissions a shipping line does not and should not hold).
|
||||
*/
|
||||
async referenceData(userId: string) {
|
||||
await this.requireShippingLine(userId);
|
||||
|
||||
const [routes, serviceTypes] = await Promise.all([
|
||||
this.bookingsRepository.manager.getRepository(Route).find({
|
||||
// Shipping lines only move inbound cargo: it lands at the Djibouti port
|
||||
// and runs inland to Ethiopia. Filtering here rather than in the portal
|
||||
// means an export or intercity lane is never offered AND never
|
||||
// accepted — `initiate` re-checks the same rule below.
|
||||
where: { status: "AVAILABLE", direction: SHIPPING_LINE_DIRECTION },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
}),
|
||||
// Customs-bundled services are excluded: those run the phased ET/DJ
|
||||
// customs workflow, which is a contract-backed flow a shipping line has
|
||||
// no part in. Their clearance is the single document set Operations
|
||||
// reviews on the booking itself.
|
||||
this.bookingsRepository.manager.getRepository(ServiceType).find({
|
||||
where: {
|
||||
canBeBookedAlone: true,
|
||||
includesCustoms: false,
|
||||
isActive: true,
|
||||
},
|
||||
order: { displayOrder: "ASC" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
routes: routes.map((route) => ({
|
||||
id: route.id,
|
||||
label: formatRouteLabel(route),
|
||||
direction: route.direction,
|
||||
originYardId: route.originYardId,
|
||||
// Per-yard labels so the portal can offer origin and destination as two
|
||||
// separate pickers (the shape the customer form uses) while still
|
||||
// resolving the pair back to one of these routes.
|
||||
originLabel:
|
||||
route.originYard?.label ?? route.originYard?.code ?? "Origin",
|
||||
destinationYardId: route.destinationYardId,
|
||||
destinationLabel:
|
||||
route.destinationYard?.label ??
|
||||
route.destinationYard?.code ??
|
||||
"Destination",
|
||||
})),
|
||||
serviceTypes: serviceTypes.map((service) => ({
|
||||
id: service.id,
|
||||
name: service.serviceName,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BARE booking for a shipping line — no contract, no cargo, no date
|
||||
* and no price. It exists so documents have something to hang off: the
|
||||
* shipping line uploads them next, Operations approves, and only then is the
|
||||
* booking completed with its cargo and shipment day.
|
||||
*/
|
||||
async initiate(userId: string, dto: InitiateShippingLineBookingDto) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
// The route is the single source of origin, destination AND direction —
|
||||
// resolved server-side so the three can never disagree, and so a caller
|
||||
// cannot post a lane EDR does not run.
|
||||
const route = await this.bookingsRepository.manager
|
||||
.getRepository(Route)
|
||||
.findOne({ where: { id: dto.routeId } });
|
||||
if (!route) {
|
||||
throw new NotFoundException(`Route ${dto.routeId} not found`);
|
||||
}
|
||||
if (route.status !== "AVAILABLE") {
|
||||
throw new BadRequestException(
|
||||
"This route is not currently available for booking.",
|
||||
);
|
||||
}
|
||||
// Enforced here too, not just by filtering the picker: the route id comes
|
||||
// from the request, so an export or intercity lane could otherwise be
|
||||
// posted directly.
|
||||
if (route.direction !== SHIPPING_LINE_DIRECTION) {
|
||||
throw new BadRequestException(
|
||||
"Shipping lines can only book inbound (Djibouti to Ethiopia) routes.",
|
||||
);
|
||||
}
|
||||
|
||||
// Same reasoning as the picker filter: a customs-bundled service would put
|
||||
// the booking into the phased customs workflow, which has no contract to
|
||||
// hang off here. Checked server-side because the id comes from the request.
|
||||
if (dto.serviceTypeId) {
|
||||
const serviceType = await this.bookingsRepository.manager
|
||||
.getRepository(ServiceType)
|
||||
.findOne({ where: { id: dto.serviceTypeId } });
|
||||
if (!serviceType) {
|
||||
throw new NotFoundException(
|
||||
`Service type ${dto.serviceTypeId} not found`,
|
||||
);
|
||||
}
|
||||
if (serviceType.includesCustoms) {
|
||||
throw new BadRequestException(
|
||||
"Shipping lines cannot book a service that bundles customs clearance.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return insertWithGeneratedReference(
|
||||
() => this.generateReference(),
|
||||
(reference) =>
|
||||
this.bookingsRepository.save({
|
||||
reference,
|
||||
// The owner columns: a shipping-line booking has no company and no
|
||||
// operational profile, which is exactly what the `chk_bookings_
|
||||
// single_owner` CHECK expects alongside a set shippingLineCompanyId.
|
||||
companyId: null,
|
||||
companyProfileId: null,
|
||||
shippingLineCompanyId: shippingLine.id,
|
||||
status: "AWAITING_DOCUMENTS",
|
||||
bookingType: "ONE_TIME",
|
||||
contractId: null,
|
||||
contractType: "NEW",
|
||||
createdByRole: "SHIPPING_LINE",
|
||||
createdByUserId: userId,
|
||||
// Taken from the chosen route, never from the request body: the
|
||||
// direction is frozen on the route from its yard countries, so
|
||||
// deriving it here keeps it consistent with scheduling and booking
|
||||
// windows, which read the same field.
|
||||
originYardId: route.originYardId,
|
||||
destinationYardId: route.destinationYardId,
|
||||
tradeDirection: route.direction,
|
||||
serviceTypeId: dto.serviceTypeId ?? null,
|
||||
freightType: dto.freightType ?? "CONTAINER",
|
||||
// Bare instance — filled in when the booking is completed.
|
||||
scheduledDate: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 0,
|
||||
} as never),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List the bookings belonging to the signed-in shipping line, newest first.
|
||||
*
|
||||
* Each row carries `hasQueriedDocuments`: a reviewer querying a document sets
|
||||
* that document's review status but leaves the BOOKING on
|
||||
* DOCUMENTS_UNDER_REVIEW, so status alone cannot tell the list which bookings
|
||||
* need the shipping line to act. Resolved in one grouped query rather than a
|
||||
* clearance call per row.
|
||||
*/
|
||||
async listMine(userId: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
const bookings = await this.bookingsRepository.find({
|
||||
where: { shippingLineCompanyId: shippingLine.id },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
if (bookings.length === 0) return [];
|
||||
|
||||
const queried = await this.bookingsRepository.manager
|
||||
.getRepository(BookingDocumentReview)
|
||||
.find({
|
||||
where: {
|
||||
bookingId: In(bookings.map((b) => b.id)),
|
||||
status: "QUERIED",
|
||||
},
|
||||
select: { bookingId: true },
|
||||
});
|
||||
|
||||
const queriedIds = new Set(queried.map((row) => row.bookingId));
|
||||
|
||||
return bookings.map((booking) => ({
|
||||
...booking,
|
||||
hasQueriedDocuments: queriedIds.has(booking.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one of the signed-in shipping line's own bookings. Scoped by owner so
|
||||
* an id belonging to a customer (or another shipping line) reads as missing.
|
||||
*/
|
||||
async findMine(userId: string, bookingId: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
|
||||
// Yards are loaded so the portal can render the lane without a second
|
||||
// lookup — they are set at initiate time from the chosen route.
|
||||
relations: { originYard: true, destinationYard: true, serviceType: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
// Same flag as the list — see listMine for why booking status alone is not
|
||||
// enough to tell whether the shipping line has something to fix.
|
||||
const queriedCount = await this.bookingsRepository.manager
|
||||
.getRepository(BookingDocumentReview)
|
||||
.count({ where: { bookingId, status: "QUERIED" } });
|
||||
|
||||
return { ...booking, hasQueriedDocuments: queriedCount > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel one of the signed-in shipping line's own bookings.
|
||||
*
|
||||
* Its own method rather than the customer `customerCancel`: that path routes
|
||||
* into `BookingTransitionService.cancel`, whose status whitelist covers the
|
||||
* contract-backed lifecycle (DRAFT, SUBMITTED, PENDING_APPROVAL…) and does
|
||||
* not include the document-clearance statuses a shipping-line booking lives
|
||||
* in — so it would reject every one of them.
|
||||
*
|
||||
* Only allowed before the booking is priced and paid. Once it carries a
|
||||
* charge, cancelling is a billing decision (fees, credit notes) that belongs
|
||||
* with Operations, not a self-service button.
|
||||
*/
|
||||
async cancelMine(userId: string, bookingId: string, reason?: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
if (booking.status === "CANCELLED") {
|
||||
throw new BadRequestException("This booking is already cancelled.");
|
||||
}
|
||||
if (!SHIPPING_LINE_CANCELLABLE_STATUSES.includes(booking.status)) {
|
||||
throw new BadRequestException(
|
||||
"This booking can no longer be cancelled — please contact Operations.",
|
||||
);
|
||||
}
|
||||
// Belt and braces: the statuses above are all pre-pricing, so a charge here
|
||||
// would mean the booking moved on in a way this guard did not anticipate.
|
||||
if (Number(booking.totalAmount ?? 0) > 0) {
|
||||
throw new BadRequestException(
|
||||
"This booking has already been priced — please contact Operations to cancel it.",
|
||||
);
|
||||
}
|
||||
|
||||
// The reason lives on the booking's review-note log, the same place the
|
||||
// customer cancel path records it — there is no column for it.
|
||||
await this.bookingsRepository.manager
|
||||
.getRepository(BookingReviewNote)
|
||||
.save({
|
||||
bookingId,
|
||||
note: reason?.trim() || "Cancelled by the shipping line",
|
||||
type: "REJECTION",
|
||||
authorId: userId,
|
||||
} as never);
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
|
||||
return this.findMine(userId, bookingId);
|
||||
}
|
||||
|
||||
/** Mirrors the customer reference format — one booking sequence per year. */
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const { max } = (await this.bookingsRepository
|
||||
.createQueryBuilder("b")
|
||||
.select(
|
||||
`COALESCE(MAX(NULLIF(regexp_replace(b.reference, '^BK-${year}-', ''), b.reference)::int), 0)`,
|
||||
"max",
|
||||
)
|
||||
.where("b.reference LIKE :prefix", { prefix: `BK-${year}-%` })
|
||||
.getRawOne<{ max: number }>()) ?? { max: 0 };
|
||||
|
||||
return `BK-${year}-${String(Number(max) + 1).padStart(6, "0")}`;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,55 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { FreightAuthModule } from "../auth/freight-auth.module";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { OtpModule } from "../otp/otp.module";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
|
||||
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
|
||||
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
|
||||
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
import { ShippingLineCreditsController } from "./shipping-line-credits.controller";
|
||||
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ShippingLineCompany, User]),
|
||||
// Booking is registered here only so this module can create shipping-line
|
||||
// rows in `freight.bookings`; the customer BookingsModule is untouched.
|
||||
TypeOrmModule.forFeature([
|
||||
ShippingLineCompany,
|
||||
ShippingLineCredit,
|
||||
User,
|
||||
Booking,
|
||||
]),
|
||||
// CustomerResetService — activation links reuse the staff-triggered reset path.
|
||||
FreightAuthModule,
|
||||
OtpModule,
|
||||
// Credits are billed by generating an ordinary invoice. Billing still knows
|
||||
// nothing about credits and hears about settlement only by emitting its own
|
||||
// `shipping_line_credit.invoice.paid` event, but the module graph now cycles
|
||||
// (billing -> companies -> here -> billing), so this edge needs forwardRef.
|
||||
forwardRef(() => BillingModule),
|
||||
],
|
||||
controllers: [ShippingLineCompaniesController],
|
||||
providers: [ShippingLineCompaniesService, ShippingLineCompaniesRepository],
|
||||
exports: [ShippingLineCompaniesService],
|
||||
controllers: [
|
||||
ShippingLineCompaniesController,
|
||||
ShippingLineBookingsController,
|
||||
ShippingLineCreditsController,
|
||||
],
|
||||
providers: [
|
||||
ShippingLineCompaniesService,
|
||||
ShippingLineCompaniesRepository,
|
||||
ShippingLineBookingsService,
|
||||
ShippingLineCreditsService,
|
||||
ShippingLineCreditsRepository,
|
||||
],
|
||||
// Exported so whatever prices a shipping-line booking can record the charge.
|
||||
exports: [ShippingLineCompaniesService, ShippingLineCreditsService],
|
||||
})
|
||||
export class ShippingLineCompaniesModule {}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff, PortalCustomer } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import {
|
||||
CancelCreditDto,
|
||||
GenerateCreditInvoiceDto,
|
||||
} from "./dto/shipping-line-credit.dto";
|
||||
import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's view of what shipping lines owe.
|
||||
*
|
||||
* A shipping line books and ships without paying — the charge is recorded as a
|
||||
* credit instead. Finance reads the unbilled list here, batches it into an
|
||||
* invoice, and the line then pays that invoice through the ordinary
|
||||
* `/billing` + CBE routes; nothing in this controller touches money directly.
|
||||
*/
|
||||
@ApiTags("shipping-line-credits")
|
||||
@Controller("shipping-line-credits")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineCreditsController {
|
||||
constructor(private readonly credits: ShippingLineCreditsService) {}
|
||||
|
||||
// Declared before the parameterised staff routes so "me" is never captured
|
||||
// as a shipping-line id.
|
||||
@Get("me")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The signed-in shipping line's own statement: outstanding balance plus its credit ledger.",
|
||||
})
|
||||
async myStatement(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
) {
|
||||
return this.credits.myStatement(
|
||||
user.id,
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":shippingLineId/outstanding")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"What one shipping line owes: unbilled + billed totals, derived from the ledger.",
|
||||
})
|
||||
async outstanding(
|
||||
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
|
||||
) {
|
||||
return this.credits.outstanding(shippingLineId);
|
||||
}
|
||||
|
||||
@Get(":shippingLineId/unbilled")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Credits that can go on an invoice for this line, oldest first. This is the selection list.",
|
||||
})
|
||||
async listUnbilled(
|
||||
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
|
||||
) {
|
||||
return this.credits.listUnbilled(shippingLineId);
|
||||
}
|
||||
|
||||
@Get(":shippingLineId")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary: "Full credit ledger for one shipping line (paginated).",
|
||||
})
|
||||
async listCredits(
|
||||
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: ShippingLineCreditStatus,
|
||||
) {
|
||||
return this.credits.listCredits(
|
||||
shippingLineId,
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoice")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoice)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.",
|
||||
})
|
||||
async generateInvoice(@Body() dto: GenerateCreditInvoiceDto) {
|
||||
return this.credits.generateInvoice(dto.creditIds, {
|
||||
dueInDays: dto.dueInDays,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(":creditId/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.cancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Write off an unbilled credit. Once billed, cancel the invoice instead.",
|
||||
})
|
||||
async cancel(
|
||||
@Param("creditId", ParseUUIDPipe) creditId: string,
|
||||
@Body() dto: CancelCreditDto,
|
||||
) {
|
||||
return this.credits.cancelCredit(creditId, dto.reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EntityManager, In, Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
OUTSTANDING_CREDIT_STATUSES,
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
|
||||
/** What one shipping line currently owes, split by billing stage. */
|
||||
export interface OutstandingTotals {
|
||||
/** Priced but not yet on an invoice. */
|
||||
unbilledAmount: number;
|
||||
/** On an issued invoice, awaiting payment. */
|
||||
billedAmount: number;
|
||||
/** `unbilledAmount + billedAmount` — the full debt. */
|
||||
totalOutstanding: number;
|
||||
unbilledCount: number;
|
||||
billedCount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineCreditsRepository extends BaseRepository<ShippingLineCredit> {
|
||||
constructor(
|
||||
@InjectRepository(ShippingLineCredit)
|
||||
private readonly credits: Repository<ShippingLineCredit>,
|
||||
) {
|
||||
super(credits);
|
||||
}
|
||||
|
||||
findByBookingId(bookingId: string): Promise<ShippingLineCredit | null> {
|
||||
return this.credits.findOne({ where: { bookingId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's worklist: everything for one line that can go on an invoice,
|
||||
* oldest first so the longest-standing debt is billed before newer charges.
|
||||
*/
|
||||
findUnbilled(shippingLineCompanyId: string): Promise<ShippingLineCredit[]> {
|
||||
return this.credits.find({
|
||||
where: {
|
||||
shippingLineCompanyId,
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
},
|
||||
relations: { booking: true },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
findByInvoiceId(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<ShippingLineCredit[]> {
|
||||
const repo = manager
|
||||
? manager.getRepository(ShippingLineCredit)
|
||||
: this.credits;
|
||||
return repo.find({ where: { invoiceId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific batch inside the caller's transaction and lock it, so two
|
||||
* concurrent invoice generations cannot both claim the same credits.
|
||||
*/
|
||||
findByIdsForUpdate(
|
||||
manager: EntityManager,
|
||||
ids: string[],
|
||||
): Promise<ShippingLineCredit[]> {
|
||||
return manager.getRepository(ShippingLineCredit).find({
|
||||
where: { id: In(ids) },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived debt — never a stored column. Grouped in one query so the detail
|
||||
* page does not fan out per status.
|
||||
*/
|
||||
async outstandingFor(
|
||||
shippingLineCompanyId: string,
|
||||
): Promise<OutstandingTotals> {
|
||||
const rows = await this.credits
|
||||
.createQueryBuilder("credit")
|
||||
.select("credit.status", "status")
|
||||
.addSelect("COALESCE(SUM(credit.amount), 0)", "amount")
|
||||
.addSelect("COUNT(*)", "count")
|
||||
.where("credit.shippingLineCompanyId = :shippingLineCompanyId", {
|
||||
shippingLineCompanyId,
|
||||
})
|
||||
.andWhere("credit.status IN (:...statuses)", {
|
||||
statuses: [...OUTSTANDING_CREDIT_STATUSES],
|
||||
})
|
||||
.andWhere("credit.deletedAt IS NULL")
|
||||
.groupBy("credit.status")
|
||||
.getRawMany<{ status: string; amount: string; count: string }>();
|
||||
|
||||
const totals = (status: ShippingLineCreditStatus) => {
|
||||
const row = rows.find((r) => r.status === status);
|
||||
return {
|
||||
amount: row ? Number(row.amount) : 0,
|
||||
count: row ? Number(row.count) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const unbilled = totals(ShippingLineCreditStatus.Unbilled);
|
||||
const billed = totals(ShippingLineCreditStatus.Billed);
|
||||
|
||||
return {
|
||||
unbilledAmount: unbilled.amount,
|
||||
billedAmount: billed.amount,
|
||||
totalOutstanding: unbilled.amount + billed.amount,
|
||||
unbilledCount: unbilled.count,
|
||||
billedCount: billed.count,
|
||||
currency: "ETB",
|
||||
};
|
||||
}
|
||||
|
||||
/** Paginated ledger for one line — every credit, whatever its status. */
|
||||
findAllPaginated(
|
||||
shippingLineCompanyId: string,
|
||||
skip: number,
|
||||
take: number,
|
||||
status?: ShippingLineCreditStatus,
|
||||
): Promise<[ShippingLineCredit[], number]> {
|
||||
return this.credits.findAndCount({
|
||||
where: {
|
||||
shippingLineCompanyId,
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
relations: { booking: true, invoice: true },
|
||||
order: { createdAt: "DESC" },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
/**
|
||||
* The money path: a shipping line ships without paying, so the debt lives
|
||||
* entirely in these three transitions. Each test below locks one way the debt
|
||||
* could be lost or double-counted.
|
||||
*/
|
||||
describe("ShippingLineCreditsService", () => {
|
||||
let creditsRepo: {
|
||||
findByIdsForUpdate: jest.Mock;
|
||||
findUnbilled: jest.Mock;
|
||||
outstandingFor: jest.Mock;
|
||||
findAllPaginated: jest.Mock;
|
||||
};
|
||||
let billing: { generateInvoice: jest.Mock };
|
||||
let shippingLines: { findById: jest.Mock; findByUserId: jest.Mock };
|
||||
let dataSource: { transaction: jest.Mock; getRepository: jest.Mock };
|
||||
let mg: {
|
||||
findOne: jest.Mock;
|
||||
getRepository: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let txRepo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock };
|
||||
let updateResult: { affected: number };
|
||||
let service: ShippingLineCreditsService;
|
||||
|
||||
const booking = {
|
||||
id: "booking-1",
|
||||
reference: "BK-2026-000001",
|
||||
shippingLineCompanyId: "sl-1",
|
||||
} as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
txRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn(async (v) => ({ id: "credit-1", ...v })),
|
||||
};
|
||||
mg = {
|
||||
findOne: jest.fn().mockResolvedValue(booking),
|
||||
getRepository: jest.fn(() => txRepo),
|
||||
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
updateResult = { affected: 2 };
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb) => cb(mg)),
|
||||
getRepository: jest.fn(() => ({
|
||||
update: jest.fn().mockResolvedValue(updateResult),
|
||||
})),
|
||||
};
|
||||
creditsRepo = {
|
||||
findByIdsForUpdate: jest.fn(),
|
||||
findUnbilled: jest.fn(),
|
||||
outstandingFor: jest.fn(),
|
||||
findAllPaginated: jest.fn(),
|
||||
};
|
||||
billing = {
|
||||
generateInvoice: jest.fn().mockResolvedValue({
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-20260813-00001",
|
||||
totalAmount: 50000,
|
||||
}),
|
||||
};
|
||||
shippingLines = {
|
||||
findById: jest.fn().mockResolvedValue({ id: "sl-1", name: "ESL" }),
|
||||
findByUserId: jest.fn(),
|
||||
};
|
||||
|
||||
service = new ShippingLineCreditsService(
|
||||
dataSource as never,
|
||||
creditsRepo as never,
|
||||
billing as never,
|
||||
shippingLines as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("recordCredit", () => {
|
||||
it("records the charge against the booking's own shipping line", async () => {
|
||||
const credit = await service.recordCredit({
|
||||
bookingId: "booking-1",
|
||||
amount: 20000,
|
||||
});
|
||||
|
||||
expect(txRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
// Taken from the booking, never from the caller.
|
||||
shippingLineCompanyId: "sl-1",
|
||||
bookingId: "booking-1",
|
||||
amount: 20000,
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
}),
|
||||
);
|
||||
expect(credit.id).toBe("credit-1");
|
||||
});
|
||||
|
||||
it("is idempotent per booking — a retried pricing step cannot double the debt", async () => {
|
||||
const existing = {
|
||||
id: "credit-existing",
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
amount: 20000,
|
||||
currency: "ETB",
|
||||
};
|
||||
txRepo.findOne.mockResolvedValue(existing);
|
||||
|
||||
const credit = await service.recordCredit({
|
||||
bookingId: "booking-1",
|
||||
amount: 20000,
|
||||
});
|
||||
|
||||
expect(credit).toBe(existing);
|
||||
expect(txRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a customer booking — those are paid up front, not on credit", async () => {
|
||||
mg.findOne.mockResolvedValue({
|
||||
...booking,
|
||||
shippingLineCompanyId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.recordCredit({ bookingId: "booking-1", amount: 100 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("rejects a negative amount", async () => {
|
||||
await expect(
|
||||
service.recordCredit({ bookingId: "booking-1", amount: -1 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateInvoice", () => {
|
||||
const unbilled = (id: string, amount: number) => ({
|
||||
id,
|
||||
shippingLineCompanyId: "sl-1",
|
||||
bookingId: `booking-${id}`,
|
||||
amount,
|
||||
currency: "ETB",
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
description: `Freight service — ${id}`,
|
||||
});
|
||||
|
||||
it("bills the batch as one invoice and flips the credits to BILLED", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
unbilled("c1", 20000),
|
||||
unbilled("c2", 30000),
|
||||
]);
|
||||
|
||||
const invoice = await service.generateInvoice(["c1", "c2"]);
|
||||
|
||||
expect(billing.generateInvoice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: Freight.InvoiceSource.ShippingLineCredit,
|
||||
// The payer, not a customer — invoices.company_id stays null.
|
||||
shippingLineCompanyId: "sl-1",
|
||||
sourceId: "sl-1",
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
lines: [
|
||||
expect.objectContaining({ amount: 20000 }),
|
||||
expect.objectContaining({ amount: 30000 }),
|
||||
],
|
||||
}),
|
||||
mg,
|
||||
);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
ShippingLineCredit,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
invoiceId: "inv-1",
|
||||
}),
|
||||
);
|
||||
expect(invoice.id).toBe("inv-1");
|
||||
});
|
||||
|
||||
it("refuses to bill a credit that is already on an invoice", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
{ ...unbilled("c1", 20000), status: ShippingLineCreditStatus.Billed },
|
||||
]);
|
||||
|
||||
await expect(service.generateInvoice(["c1"])).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(billing.generateInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to mix two shipping lines on one invoice", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
unbilled("c1", 20000),
|
||||
{ ...unbilled("c2", 30000), shippingLineCompanyId: "sl-2" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.generateInvoice(["c1", "c2"]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(billing.generateInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to mix currencies", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
unbilled("c1", 20000),
|
||||
{ ...unbilled("c2", 300), currency: "USD" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.generateInvoice(["c1", "c2"]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("reports ids that do not exist rather than silently billing the rest", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([unbilled("c1", 20000)]);
|
||||
|
||||
await expect(
|
||||
service.generateInvoice(["c1", "missing"]),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("rejects an empty selection", async () => {
|
||||
await expect(service.generateInvoice([])).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onInvoicePaid", () => {
|
||||
it("clears every billed credit on the settled invoice", async () => {
|
||||
const update = jest.fn().mockResolvedValue({ affected: 2 });
|
||||
dataSource.getRepository = jest.fn(() => ({ update }));
|
||||
|
||||
await service.onInvoicePaid({
|
||||
invoiceId: "inv-1",
|
||||
invoiceNumber: "INV-20260813-00001",
|
||||
} as never);
|
||||
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
// Scoped to BILLED so a redelivered webhook cannot re-stamp paidAt.
|
||||
{ invoiceId: "inv-1", status: ShippingLineCreditStatus.Billed },
|
||||
expect.objectContaining({ status: ShippingLineCreditStatus.Paid }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op on webhook redelivery", async () => {
|
||||
const update = jest.fn().mockResolvedValue({ affected: 0 });
|
||||
dataSource.getRepository = jest.fn(() => ({ update }));
|
||||
|
||||
await expect(
|
||||
service.onInvoicePaid({
|
||||
invoiceId: "inv-1",
|
||||
invoiceNumber: "INV-1",
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelCredit", () => {
|
||||
it("writes off an unbilled credit", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
{ id: "c1", status: ShippingLineCreditStatus.Unbilled },
|
||||
]);
|
||||
|
||||
const result = await service.cancelCredit("c1", "Booking voided");
|
||||
|
||||
expect(result.status).toBe(ShippingLineCreditStatus.Cancelled);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
ShippingLineCredit,
|
||||
{ id: "c1" },
|
||||
expect.objectContaining({
|
||||
status: ShippingLineCreditStatus.Cancelled,
|
||||
cancellationReason: "Booking voided",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to write off a credit already on an invoice", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
{
|
||||
id: "c1",
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
invoiceId: "inv-1",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.cancelCredit("c1", "oops"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
InvoiceEventPayload,
|
||||
InvoiceLineInput,
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/** A charge to record against a shipping line's booking. */
|
||||
export interface RecordCreditInput {
|
||||
bookingId: string;
|
||||
/** Frozen at this value; never recalculated afterwards. */
|
||||
amount: number;
|
||||
currency?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Payment terms for a generated shipping-line invoice. */
|
||||
export interface GenerateCreditInvoiceOptions {
|
||||
/** Pay window in days; defaults to the billing module's own default. */
|
||||
dueInDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The credit ledger for shipping lines — "use the service now, pay later".
|
||||
*
|
||||
* Three moments, in order:
|
||||
*
|
||||
* 1. **Charge.** A shipping line's booking is priced, and
|
||||
* {@link recordCredit} writes an UNBILLED credit. No invoice, no payment
|
||||
* intent, no gate on the booking — it proceeds regardless.
|
||||
* 2. **Bill.** Finance picks a batch of unbilled credits for ONE line and
|
||||
* {@link generateInvoice} turns them into a single invoice, one line per
|
||||
* credit. The credits become BILLED.
|
||||
* 3. **Settle.** The line pays that invoice through the ordinary CBE flow.
|
||||
* Billing emits `shipping_line_credit.invoice.paid`, {@link onInvoicePaid}
|
||||
* marks the batch PAID, and the debt disappears.
|
||||
*
|
||||
* Nothing here decrements a balance: the amount owed is always
|
||||
* `SUM(amount)` over non-terminal credits. Payment is settled by the gateway
|
||||
* webhook alone — no manual approval step — so a credit only ever leaves debt
|
||||
* because real money arrived.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShippingLineCreditsService {
|
||||
private readonly logger = new Logger(ShippingLineCreditsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly credits: ShippingLineCreditsRepository,
|
||||
private readonly billing: BillingService,
|
||||
private readonly shippingLines: ShippingLineCompaniesService,
|
||||
) {}
|
||||
|
||||
// ── 1. Charge ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record what a shipping line owes for one booking.
|
||||
*
|
||||
* Called when the booking is priced. The owner is read off the booking
|
||||
* itself rather than passed in, so a credit can never be filed against the
|
||||
* wrong line. Idempotent per booking: a second call returns the existing
|
||||
* credit untouched rather than doubling the debt — safe against a retried
|
||||
* pricing step, and the partial unique index backs it at the DB level.
|
||||
*
|
||||
* Pass `manager` to enlist in the caller's transaction, so the credit and
|
||||
* whatever priced the booking commit together.
|
||||
*/
|
||||
async recordCredit(
|
||||
input: RecordCreditInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<ShippingLineCredit> {
|
||||
if (!(input.amount >= 0)) {
|
||||
throw new BadRequestException("Credit amount cannot be negative.");
|
||||
}
|
||||
|
||||
const run = async (mg: EntityManager): Promise<ShippingLineCredit> => {
|
||||
const booking = await mg.findOne(Booking, {
|
||||
where: { id: input.bookingId },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${input.bookingId} not found`);
|
||||
}
|
||||
if (!booking.shippingLineCompanyId) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${booking.reference} is not a shipping-line booking — customer bookings are billed up front, not on credit.`,
|
||||
);
|
||||
}
|
||||
|
||||
const repo = mg.getRepository(ShippingLineCredit);
|
||||
const existing = await repo.findOne({
|
||||
where: { bookingId: input.bookingId },
|
||||
});
|
||||
if (existing && existing.status !== ShippingLineCreditStatus.Cancelled) {
|
||||
this.logger.warn(
|
||||
`Credit already exists for booking ${booking.reference} (${existing.status}, ${existing.amount} ${existing.currency}) — leaving it unchanged.`,
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const credit = await repo.save(
|
||||
repo.create({
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
bookingId: input.bookingId,
|
||||
amount: input.amount,
|
||||
currency: input.currency ?? "ETB",
|
||||
description:
|
||||
input.description ?? `Freight service — booking ${booking.reference}`,
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
}),
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
creditId: credit.id,
|
||||
bookingId: credit.bookingId,
|
||||
shippingLineCompanyId: credit.shippingLineCompanyId,
|
||||
amount: credit.amount,
|
||||
},
|
||||
{ path: "shippingLineCredit.recorded" },
|
||||
);
|
||||
|
||||
return credit;
|
||||
};
|
||||
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
// ── 2. Bill ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Turn a batch of unbilled credits into one invoice.
|
||||
*
|
||||
* Every credit must belong to the SAME shipping line — one invoice has one
|
||||
* payer, so a mixed batch is rejected rather than silently split. The whole
|
||||
* thing runs in one transaction with the credits locked FOR UPDATE, so two
|
||||
* finance users clicking at once cannot bill the same credit twice: the
|
||||
* second transaction blocks, then finds the rows already BILLED and fails.
|
||||
*/
|
||||
async generateInvoice(
|
||||
creditIds: string[],
|
||||
options: GenerateCreditInvoiceOptions = {},
|
||||
): Promise<Invoice> {
|
||||
if (creditIds.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"Select at least one credit to invoice.",
|
||||
);
|
||||
}
|
||||
const uniqueIds = [...new Set(creditIds)];
|
||||
|
||||
return this.dataSource.transaction(async (mg) => {
|
||||
const credits = await this.credits.findByIdsForUpdate(mg, uniqueIds);
|
||||
|
||||
const missing = uniqueIds.filter(
|
||||
(id) => !credits.some((c) => c.id === id),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new NotFoundException(
|
||||
`Credit(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const alreadyBilled = credits.filter(
|
||||
(c) => c.status !== ShippingLineCreditStatus.Unbilled,
|
||||
);
|
||||
if (alreadyBilled.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`These credits are no longer unbilled and cannot be invoiced: ${alreadyBilled
|
||||
.map((c) => `${c.id} (${c.status})`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const lineIds = new Set(credits.map((c) => c.shippingLineCompanyId));
|
||||
if (lineIds.size > 1) {
|
||||
throw new BadRequestException(
|
||||
"All selected credits must belong to the same shipping line — one invoice has one payer.",
|
||||
);
|
||||
}
|
||||
const shippingLineCompanyId = credits[0].shippingLineCompanyId;
|
||||
|
||||
const currencies = new Set(credits.map((c) => c.currency));
|
||||
if (currencies.size > 1) {
|
||||
throw new BadRequestException(
|
||||
`Cannot mix currencies on one invoice: ${[...currencies].join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const currency = credits[0].currency;
|
||||
|
||||
const shippingLine = await this.shippingLines.findById(
|
||||
shippingLineCompanyId,
|
||||
);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException(
|
||||
`Shipping line ${shippingLineCompanyId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const lines: InvoiceLineInput[] = credits.map((credit) => ({
|
||||
chargeType: "SHIPPING_LINE_SERVICE",
|
||||
description: credit.description ?? undefined,
|
||||
quantity: 1,
|
||||
unitRate: Number(credit.amount),
|
||||
amount: Number(credit.amount),
|
||||
currency: credit.currency,
|
||||
metadata: { creditId: credit.id, bookingId: credit.bookingId },
|
||||
}));
|
||||
|
||||
const invoice = await this.billing.generateInvoice(
|
||||
{
|
||||
source: Freight.InvoiceSource.ShippingLineCredit,
|
||||
// Unlike other sources this is the payer, not a single billed
|
||||
// record: the invoice spans many bookings, and each credit keeps its
|
||||
// own booking link.
|
||||
sourceId: shippingLineCompanyId,
|
||||
type: "SHIPPING_LINE_CREDIT",
|
||||
shippingLineCompanyId,
|
||||
currency,
|
||||
lines,
|
||||
dueInDays: options.dueInDays,
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
},
|
||||
mg,
|
||||
);
|
||||
|
||||
const billedAt = new Date();
|
||||
await mg.update(
|
||||
ShippingLineCredit,
|
||||
{ id: In(credits.map((c) => c.id)) },
|
||||
{
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
invoiceId: invoice.id,
|
||||
billedAt,
|
||||
},
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
shippingLineCompanyId,
|
||||
creditCount: credits.length,
|
||||
totalAmount: invoice.totalAmount,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiced" },
|
||||
);
|
||||
|
||||
return invoice;
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Settle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clear the batch once its invoice is paid.
|
||||
*
|
||||
* Driven by the billing event rather than a call inside the payment path, so
|
||||
* the CBE webhook flow needs no knowledge of credits: whatever settles the
|
||||
* invoice — gateway webhook, or a finance-recorded offline payment — this
|
||||
* fires. Idempotent, because a redelivered webhook re-emits the event.
|
||||
*/
|
||||
@OnEvent("shipping_line_credit.invoice.paid")
|
||||
async onInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
const result = await this.dataSource
|
||||
.getRepository(ShippingLineCredit)
|
||||
.update(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
},
|
||||
{ status: ShippingLineCreditStatus.Paid, paidAt: new Date() },
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
invoiceNumber: payload.invoiceNumber,
|
||||
creditsCleared: result.affected ?? 0,
|
||||
},
|
||||
{ path: "shippingLineCredit.settled" },
|
||||
);
|
||||
|
||||
// Zero is the ordinary idempotent no-op on webhook redelivery. It is only
|
||||
// worth a line in the log, not an error: the invoice is paid either way.
|
||||
if (!result.affected) {
|
||||
this.logger.log(
|
||||
`Invoice ${payload.invoiceNumber} paid — no BILLED credits left to clear (already settled).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Finance's worklist: what can go on an invoice for this line right now. */
|
||||
async listUnbilled(shippingLineCompanyId: string) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
const credits = await this.credits.findUnbilled(shippingLineCompanyId);
|
||||
return {
|
||||
items: credits,
|
||||
totalAmount: credits.reduce((sum, c) => sum + Number(c.amount), 0),
|
||||
currency: credits[0]?.currency ?? "ETB",
|
||||
};
|
||||
}
|
||||
|
||||
/** The debt figure shown on the shipping-line detail page. */
|
||||
async outstanding(shippingLineCompanyId: string) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
return this.credits.outstandingFor(shippingLineCompanyId);
|
||||
}
|
||||
|
||||
/** Full ledger for one line, newest first. */
|
||||
async listCredits(
|
||||
shippingLineCompanyId: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status?: ShippingLineCreditStatus,
|
||||
) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
const [items, total] = await this.credits.findAllPaginated(
|
||||
shippingLineCompanyId,
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
status,
|
||||
);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in shipping line's own statement: what it owes and why.
|
||||
* Resolves the line from the session, so one line can never read another's.
|
||||
*/
|
||||
async myStatement(userId: string, page = 1, pageSize = 20) {
|
||||
const shippingLine = await this.shippingLines.findByUserId(userId);
|
||||
if (!shippingLine) {
|
||||
throw new ForbiddenException("This account is not a shipping line.");
|
||||
}
|
||||
const [outstanding, ledger] = await Promise.all([
|
||||
this.credits.outstandingFor(shippingLine.id),
|
||||
this.credits.findAllPaginated(
|
||||
shippingLine.id,
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
),
|
||||
]);
|
||||
return {
|
||||
outstanding,
|
||||
items: ledger[0],
|
||||
total: ledger[1],
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Cancellation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write off an unbilled credit (booking voided, charge raised in error).
|
||||
* Only UNBILLED credits can be cancelled — once a credit is on an issued
|
||||
* invoice, the invoice is what has to be cancelled or credited, otherwise
|
||||
* the invoice total would stop matching the sum of its lines.
|
||||
*/
|
||||
async cancelCredit(
|
||||
creditId: string,
|
||||
reason: string,
|
||||
): Promise<ShippingLineCredit> {
|
||||
return this.dataSource.transaction(async (mg) => {
|
||||
const [credit] = await this.credits.findByIdsForUpdate(mg, [creditId]);
|
||||
if (!credit) {
|
||||
throw new NotFoundException(`Credit ${creditId} not found`);
|
||||
}
|
||||
if (credit.status !== ShippingLineCreditStatus.Unbilled) {
|
||||
throw new BadRequestException(
|
||||
`Only an unbilled credit can be cancelled; this one is ${credit.status}. Cancel or credit invoice ${credit.invoiceId} instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
await mg.update(
|
||||
ShippingLineCredit,
|
||||
{ id: creditId },
|
||||
{
|
||||
status: ShippingLineCreditStatus.Cancelled,
|
||||
cancelledAt: new Date(),
|
||||
cancellationReason: reason,
|
||||
},
|
||||
);
|
||||
|
||||
return { ...credit, status: ShippingLineCreditStatus.Cancelled };
|
||||
});
|
||||
}
|
||||
|
||||
private async requireShippingLine(shippingLineCompanyId: string) {
|
||||
const shippingLine = await this.shippingLines.findById(
|
||||
shippingLineCompanyId,
|
||||
);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException(
|
||||
`Shipping line ${shippingLineCompanyId} not found`,
|
||||
);
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user