feat(WIP): Wire up the invoice with the booking.

This commit is contained in:
Nathnael
2026-06-29 08:29:07 +00:00
parent 36711eace4
commit 9cd24d9b51
8 changed files with 208 additions and 2 deletions

View File

@@ -0,0 +1,152 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
InvoiceLineInput,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
interface StoredPricingBreakdown {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
}
/** Round to 2 decimals, avoiding binary float drift. */
const round2 = (n: number): number => Math.round(n * 100) / 100;
/**
* Owns the booking ⇄ invoice mapping — the one place that knows how a booking
* turns into invoices and which type to use. Bookings are the billable business
* entity, so they generate their own invoices directly via {@link BillingService}
* (billing stays source-agnostic). All booking-specific type branching lives here,
* at the two points it belongs: invoice creation and settlement (the paid handler).
*/
@Injectable()
export class BookingInvoiceService {
private readonly logger = new Logger(BookingInvoiceService.name);
constructor(private readonly billing: BillingService) {}
/**
* Ensure the booking has its invoice, generating one from the snapshotted
* pricing breakdown if absent. Called when a booking reaches a billable state.
* Idempotent — returns the existing open invoice instead of a duplicate.
* Returns `null` (and logs) when the booking is not billable: no company to
* bill (e.g. government bookings whose `companyId` is null, which the invoices
* FK requires), or no priced amount.
*/
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
booking.id,
Freight.InvoiceType.Prepaid,
);
if (existing) return existing;
if (!booking.companyId) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
);
return null;
}
const input = this.buildInput(booking);
if (!input) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
);
return null;
}
return this.billing.generateInvoice(input);
}
/**
* React to a booking invoice being paid — the settlement branch point. Per-type
* reactions live here (not in the payment process): e.g. a paid up-front invoice
* may later generate a final invoice. Only PREPAID exists today.
*/
@OnEvent('booking.invoice.paid')
onBookingInvoicePaid(payload: InvoiceEventPayload): void {
switch (payload.type) {
case Freight.InvoiceType.Prepaid:
// Full prepaid settlement — booking advancement is handled by the
// payment flow today. Final-invoice issuance would hook in here.
break;
default:
this.logger.warn(
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
);
}
}
/** Map a booking's pricing snapshot into a generic invoice request. */
private buildInput(booking: Booking): GenerateInvoiceInput | null {
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
chargeType: l.code,
description: l.description,
quantity: l.quantity,
unitRate: l.unitAmount,
amount: l.amount,
currency: l.currency ?? currency,
metadata: l.unit ? { unit: l.unit } : null,
}));
// Fall back to a single freight line when no breakdown was snapshotted.
if (lines.length === 0) {
const amount = Number(booking.totalAmount);
if (!Number.isFinite(amount) || amount <= 0) return null;
lines.push({
chargeType: 'FREIGHT',
description: 'Rail freight',
quantity: 1,
unitRate: amount,
amount,
currency,
});
}
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
let totalAmount = subtotal;
// Honor a staff price override: bill the adjusted total, recording the delta
// as an ADJUSTMENT line so the lines still sum to the invoice total.
const adjusted = booking.adjustedTotalAmount;
if (adjusted != null && Number.isFinite(Number(adjusted))) {
const delta = round2(Number(adjusted) - subtotal);
if (delta !== 0) {
lines.push({
chargeType: 'ADJUSTMENT',
description: 'Staff price adjustment',
quantity: 1,
unitRate: delta,
amount: delta,
currency,
});
}
totalAmount = round2(Number(adjusted));
}
return {
source: Freight.InvoiceSource.Booking,
sourceId: booking.id,
type: Freight.InvoiceType.Prepaid,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency,
lines,
totalAmount,
};
}
}

View File

@@ -30,6 +30,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService

View File

@@ -41,6 +41,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never, // bookingBatchService
@@ -122,6 +123,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,
@@ -189,6 +191,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,

View File

@@ -34,6 +34,7 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,

View File

@@ -3,6 +3,7 @@ import {
forwardRef,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -14,6 +15,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
@@ -26,11 +28,14 @@ import { BookingsService } from './bookings.service';
@Injectable()
export class BookingTransitionService {
private readonly logger = new Logger(BookingTransitionService.name);
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
private readonly invoiceService: BookingInvoiceService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
@Inject(forwardRef(() => BookingBatchService))
@@ -404,7 +409,22 @@ export class BookingTransitionService {
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
const executed = await this.bookingsService.findById(updated!.id);
// Billable state reached — generate the invoice payment will settle.
// Non-blocking: a billing hiccup must not undo the execution.
await this.invoiceService
.ensureInvoiceForBooking(executed)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for booking ${executed.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return executed;
}
async startTransit(bookingId: string): Promise<Booking> {

View File

@@ -10,7 +10,9 @@ import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
@@ -49,6 +51,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingContractSignature,
]),
PaymentModule,
BillingModule,
forwardRef(() => TrainSchedulingModule),
FilesModule,
MinioModule,
@@ -72,6 +75,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingPricingService,
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
BookingPaymentService,
ContractTemplateResolver,
ContractViewModelBuilder,

View File

@@ -12,6 +12,7 @@ import {
} from "@edr/types";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { BillingModule } from "../billing/billing.module";
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
import { FirstMileModule } from "../first-mile/first-mile.module";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
@@ -59,6 +60,7 @@ function rabbitMQImport(): DynamicModule[] {
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
BillingModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([

View File

@@ -22,11 +22,13 @@ import {
ProviderPaymentStatus,
} from "@edr/payment-providers";
import {
Freight,
PaymentService as PaymentServiceEnum,
PaymentReferenceType,
PaymentIntentSnapshot,
ProviderMethod,
} from "@edr/types";
import { BillingService } from "../billing/billing.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
@@ -62,6 +64,7 @@ export class PaymentService {
private readonly bookingBatchService: BookingBatchService,
private readonly dropdownSettings: DropdownSettingsService,
private readonly firstMileService: FirstMileService,
private readonly billing: BillingService,
) { }
/** Configured general-contract ordering window in months (defaults to 3). */
@@ -167,7 +170,16 @@ export class PaymentService {
.findOneBy({ id: dto.bookingId });
if (!booking) throw new NotFoundException("Booking not found");
const amountMinor = Math.round(Number(booking.totalAmount));
// Charge the invoice (the billing document of record) so discounts,
// penalties and staff adjustments carried on it are honored. Fall back to
// the booking total only when no invoice has been generated yet.
const invoice = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
booking.id,
);
const amountMinor = Math.round(
Number(invoice?.totalAmount ?? booking.totalAmount),
);
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
@@ -344,6 +356,17 @@ export class PaymentService {
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
: { paymentStatus: "PAID", status: "PAID" },
);
// Settle the booking's open invoice in the same transaction and link
// this payment. The invoice emits `booking.invoice.paid` for the source
// to react to. No-op if the booking has no open invoice.
await this.billing.settlePayable(
Freight.InvoiceSource.Booking,
input.bookingId,
intent.id,
mg,
);
await this.firstMileService.acceptBooking(input.bookingId);
});