mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
chore: updating billing logic
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
import { Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
@@ -21,4 +21,16 @@ export class BillingController {
|
|||||||
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||||
return this.billingService.findByBooking(bookingId);
|
return this.billingService.findByBooking(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("invoices/:id")
|
||||||
|
@ApiOperation({ summary: "Get an invoice with its line items" })
|
||||||
|
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.billingService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("invoices/generate/:bookingId")
|
||||||
|
@ApiOperation({ summary: "Generate (or return the existing) invoice for a booking" })
|
||||||
|
generate(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.billingService.generateForBooking(bookingId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
147
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
147
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { BillingService } from "./billing.service";
|
||||||
|
import type { Invoice } from "./entities/invoice.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal in-memory EntityManager stand-in covering the methods
|
||||||
|
* `generateForBooking` / `markBookingInvoicePaid` call on the transaction manager.
|
||||||
|
*/
|
||||||
|
function makeManager(savedLines: unknown[]) {
|
||||||
|
return {
|
||||||
|
create: (_entity: unknown, data: Record<string, unknown>) => data,
|
||||||
|
save: (data: Record<string, unknown>) => {
|
||||||
|
const row = { id: data.id ?? `gen-${Math.round(0)}`, ...data };
|
||||||
|
if (data.invoiceId) savedLines.push(row);
|
||||||
|
return Promise.resolve(row);
|
||||||
|
},
|
||||||
|
query: () => Promise.resolve([{ seq: 0 }]),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBooking(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "booking-1",
|
||||||
|
reference: "BK-001",
|
||||||
|
companyId: "company-1",
|
||||||
|
companyProfileId: "profile-1",
|
||||||
|
paymentCurrency: "ETB",
|
||||||
|
totalAmount: 1500,
|
||||||
|
pricingBreakdown: {
|
||||||
|
currency: "ETB",
|
||||||
|
totalAmount: 1500,
|
||||||
|
lineItems: [
|
||||||
|
{ code: "RAIL_FREIGHT", description: "Rail freight", amount: 1000, unitAmount: 500, unit: "PER_CONTAINER", quantity: 2, currency: "ETB" },
|
||||||
|
{ code: "HAZARD_SURCHARGE", description: "Hazard surcharge", amount: 500, unitAmount: 250, unit: "PER_CONTAINER", quantity: 2, currency: "ETB" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BillingService.generateForBooking", () => {
|
||||||
|
let invoices: { findAll: jest.Mock; findById: jest.Mock };
|
||||||
|
let invoiceLines: { findAll: jest.Mock };
|
||||||
|
let savedLines: unknown[];
|
||||||
|
let manager: ReturnType<typeof makeManager>;
|
||||||
|
let bookingRow: Record<string, unknown> | null;
|
||||||
|
let dataSource: {
|
||||||
|
getRepository: jest.Mock;
|
||||||
|
transaction: jest.Mock;
|
||||||
|
manager: unknown;
|
||||||
|
};
|
||||||
|
let service: BillingService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
savedLines = [];
|
||||||
|
manager = makeManager(savedLines);
|
||||||
|
invoices = { findAll: jest.fn().mockResolvedValue([]), findById: jest.fn() };
|
||||||
|
invoiceLines = { findAll: jest.fn().mockResolvedValue([]) };
|
||||||
|
bookingRow = makeBooking();
|
||||||
|
dataSource = {
|
||||||
|
getRepository: jest.fn().mockReturnValue({
|
||||||
|
findOne: jest.fn().mockImplementation(() => Promise.resolve(bookingRow)),
|
||||||
|
}),
|
||||||
|
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||||
|
manager,
|
||||||
|
};
|
||||||
|
service = new BillingService(dataSource as never, invoices as never, invoiceLines as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a PENDING invoice with one line per pricing line item", async () => {
|
||||||
|
const invoice = (await service.generateForBooking("booking-1")) as Invoice;
|
||||||
|
|
||||||
|
expect(invoice).toBeTruthy();
|
||||||
|
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
|
||||||
|
expect(invoice.companyId).toBe("company-1");
|
||||||
|
expect(invoice.companyProfileId).toBe("profile-1");
|
||||||
|
expect(invoice.source).toBe("booking");
|
||||||
|
expect(invoice.sourceId).toBe("booking-1");
|
||||||
|
expect(invoice.totalAmount).toBe(1500);
|
||||||
|
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
|
||||||
|
expect(savedLines).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the existing active invoice instead of creating a duplicate", async () => {
|
||||||
|
const existing = { id: "inv-existing", status: Freight.InvoiceStatus.Pending } as Invoice;
|
||||||
|
invoices.findAll.mockResolvedValueOnce([existing]);
|
||||||
|
|
||||||
|
const invoice = await service.generateForBooking("booking-1");
|
||||||
|
|
||||||
|
expect(invoice).toBe(existing);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips generation (returns null) when the booking has no company to bill", async () => {
|
||||||
|
bookingRow = makeBooking({ companyId: null, companyProfileId: null });
|
||||||
|
|
||||||
|
const invoice = await service.generateForBooking("booking-1");
|
||||||
|
|
||||||
|
expect(invoice).toBeNull();
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a single freight line when no pricing breakdown exists", async () => {
|
||||||
|
bookingRow = makeBooking({ pricingBreakdown: null });
|
||||||
|
|
||||||
|
const invoice = (await service.generateForBooking("booking-1")) as Invoice;
|
||||||
|
|
||||||
|
expect(invoice.totalAmount).toBe(1500);
|
||||||
|
expect(savedLines).toHaveLength(1);
|
||||||
|
expect((savedLines[0] as { chargeType: string }).chargeType).toBe("FREIGHT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BillingService.markBookingInvoicePaid", () => {
|
||||||
|
it("marks the open booking invoice PAID and links the payment", async () => {
|
||||||
|
const open = { id: "inv-1", status: Freight.InvoiceStatus.Pending };
|
||||||
|
const mg = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(open),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const dataSource = { manager: mg } as never;
|
||||||
|
const service = new BillingService(dataSource, {} as never, {} as never);
|
||||||
|
|
||||||
|
await service.markBookingInvoicePaid("booking-1", "pay-1", mg as never);
|
||||||
|
|
||||||
|
expect(mg.update).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
{ id: "inv-1" },
|
||||||
|
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the booking has no open invoice", async () => {
|
||||||
|
const mg = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const service = new BillingService({ manager: mg } as never, {} as never, {} as never);
|
||||||
|
|
||||||
|
await service.markBookingInvoicePaid("booking-1", "pay-1", mg as never);
|
||||||
|
|
||||||
|
expect(mg.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,26 +1,333 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { Freight } from "@edr/types";
|
||||||
import { Repository } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
|
|
||||||
|
/** Source tag stamped on booking invoices (`source` column). */
|
||||||
|
const BOOKING_SOURCE = "booking";
|
||||||
|
|
||||||
|
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||||
|
const DEFAULT_DUE_DAYS = 14;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses an invoice can hold while it still represents the live bill for a
|
||||||
|
* booking. A second `generateForBooking` call returns the existing one of these
|
||||||
|
* instead of creating a duplicate (idempotency / dedup guard).
|
||||||
|
*/
|
||||||
|
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
|
||||||
|
Freight.InvoiceStatus.Draft,
|
||||||
|
Freight.InvoiceStatus.Pending,
|
||||||
|
Freight.InvoiceStatus.Paid,
|
||||||
|
Freight.InvoiceStatus.Overdue,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Shape of a single line inside `booking.pricingBreakdown.lineItems`. */
|
||||||
|
interface StoredPriceLine {
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
unitAmount: number;
|
||||||
|
unit: string;
|
||||||
|
quantity: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredPricingBreakdown {
|
||||||
|
lineItems?: StoredPriceLine[];
|
||||||
|
totalAmount?: number;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fully-resolved invoice line ready to persist. */
|
||||||
|
interface BuiltLine {
|
||||||
|
chargeType: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitRate: number;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
|
private readonly logger = new Logger(BillingService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Invoice)
|
private readonly dataSource: DataSource,
|
||||||
private readonly invoicesRepository: Repository<Invoice>,
|
private readonly invoices: InvoiceRepository,
|
||||||
|
private readonly invoiceLines: InvoiceLineRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** List every invoice (most recent first). */
|
/** List every invoice (most recent first). */
|
||||||
findAll(): Promise<Invoice[]> {
|
findAll(): Promise<Invoice[]> {
|
||||||
return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
|
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List invoices for a given booking. */
|
/** List invoices for a given booking. */
|
||||||
findByBooking(bookingId: string): Promise<Invoice[]> {
|
findByBooking(bookingId: string): Promise<Invoice[]> {
|
||||||
return this.invoicesRepository.find({
|
return this.invoices.findAll({
|
||||||
where: { bookingId },
|
where: { source: BOOKING_SOURCE, sourceId: bookingId },
|
||||||
order: { issuedAt: "DESC" },
|
order: { issuedAt: "DESC" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Invoice header plus its line items. */
|
||||||
|
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
|
const invoice = await this.invoices.findById(id);
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
|
const lines = await this.invoiceLines.findAll({
|
||||||
|
where: { invoiceId: id },
|
||||||
|
order: { createdAt: "ASC" },
|
||||||
|
});
|
||||||
|
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the booking's invoice from its snapshotted pricing breakdown.
|
||||||
|
*
|
||||||
|
* Called when a booking reaches a billable state (full contract execution /
|
||||||
|
* contract activation). Idempotent: a booking that already has an active
|
||||||
|
* invoice gets that invoice back instead of a duplicate.
|
||||||
|
*
|
||||||
|
* Returns `null` (and logs) when the booking is not billable yet — no pricing
|
||||||
|
* breakdown, or no customer company/profile to bill (e.g. government/legacy
|
||||||
|
* bookings whose `company_id` is null, which the `invoices` FK requires).
|
||||||
|
*/
|
||||||
|
async generateForBooking(bookingId: string): Promise<Invoice | null> {
|
||||||
|
const existing = await this.invoices.findAll({
|
||||||
|
where: { source: BOOKING_SOURCE, sourceId: bookingId },
|
||||||
|
});
|
||||||
|
const active = existing.find((inv) => ACTIVE_STATUSES.includes(inv.status));
|
||||||
|
if (active) return active;
|
||||||
|
|
||||||
|
const booking = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: bookingId } });
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
|
||||||
|
if (!booking.companyId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Skipping invoice for booking ${booking.reference} (${bookingId}): no company to bill.`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const companyId = booking.companyId;
|
||||||
|
const companyProfileId = booking.companyProfileId ?? null;
|
||||||
|
|
||||||
|
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
|
||||||
|
const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";
|
||||||
|
const lines = this.buildLines(breakdown, booking.totalAmount, currency);
|
||||||
|
const subtotal = round2(lines.reduce((sum, l) => sum + l.amount, 0));
|
||||||
|
|
||||||
|
// Honor a staff price override: bill the adjusted total, recording the delta
|
||||||
|
// as an ADJUSTMENT line so the signed lines still sum to the invoice total.
|
||||||
|
const adjusted = booking.adjustedTotalAmount;
|
||||||
|
let total = subtotal;
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
total = round2(Number(adjusted));
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuedAt = new Date();
|
||||||
|
const dueAt = new Date(issuedAt);
|
||||||
|
dueAt.setDate(dueAt.getDate() + DEFAULT_DUE_DAYS);
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (mg) => {
|
||||||
|
const invoice = await mg.save(
|
||||||
|
mg.create(Invoice, {
|
||||||
|
invoiceNumber: await this.nextInvoiceNumber(mg),
|
||||||
|
companyId,
|
||||||
|
companyProfileId,
|
||||||
|
totalAmount: total,
|
||||||
|
currency,
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
source: BOOKING_SOURCE,
|
||||||
|
sourceId: bookingId,
|
||||||
|
type: "PREPAID",
|
||||||
|
issuedAt,
|
||||||
|
dueAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
await mg.save(mg.create(InvoiceLine, { invoiceId: invoice.id, ...line }));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Generated invoice ${invoice.invoiceNumber} for booking ${booking.reference} (${total} ${currency}).`,
|
||||||
|
);
|
||||||
|
return invoice;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map the stored pricing line items to invoice lines (one breakdown line → one invoice line). */
|
||||||
|
private buildLines(
|
||||||
|
breakdown: StoredPricingBreakdown,
|
||||||
|
fallbackTotal: number,
|
||||||
|
currency: string,
|
||||||
|
): BuiltLine[] {
|
||||||
|
const items = breakdown.lineItems ?? [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
// No itemized breakdown — bill a single line for the booking total.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
chargeType: "FREIGHT",
|
||||||
|
description: "Freight charge",
|
||||||
|
quantity: 1,
|
||||||
|
unitRate: round2(fallbackTotal),
|
||||||
|
amount: round2(fallbackTotal),
|
||||||
|
currency,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item) => ({
|
||||||
|
chargeType: item.code,
|
||||||
|
description: item.description,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitRate: round2(item.unitAmount),
|
||||||
|
amount: round2(item.amount),
|
||||||
|
currency: item.currency ?? currency,
|
||||||
|
metadata: { unit: item.unit },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
||||||
|
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||||
|
const now = new Date();
|
||||||
|
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
const prefix = `FRT-${ymd}-`;
|
||||||
|
const [row] = await mg.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||||
|
FROM freight.invoices WHERE invoice_number LIKE $1`,
|
||||||
|
[`${prefix}%`],
|
||||||
|
);
|
||||||
|
const next = Number(row?.seq ?? 0) + 1;
|
||||||
|
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment reconciliation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The invoice a gateway payment should settle for a booking, or null if none.
|
||||||
|
*
|
||||||
|
* This is the billing document of record for "what is owed" — callers (e.g.
|
||||||
|
* `payment.service.initiatePayment`) should charge `invoice.totalAmount` against
|
||||||
|
* it rather than recomputing from `booking.totalAmount`, so discounts/penalties/
|
||||||
|
* adjustments carried on the invoice are honored. Returns the most recent open
|
||||||
|
* (unpaid, non-cancelled) invoice.
|
||||||
|
*/
|
||||||
|
findPayableForBooking(bookingId: string): Promise<Invoice | null> {
|
||||||
|
return this.dataSource.getRepository(Invoice).findOne({
|
||||||
|
where: {
|
||||||
|
source: BOOKING_SOURCE,
|
||||||
|
sourceId: bookingId,
|
||||||
|
status: In([
|
||||||
|
Freight.InvoiceStatus.Pending,
|
||||||
|
Freight.InvoiceStatus.Draft,
|
||||||
|
Freight.InvoiceStatus.Overdue,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
order: { issuedAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a booking's paid invoice as refunded. Called from `payment.service.refund`
|
||||||
|
* inside its DB transaction so the invoice tracks the booking/payment reversal.
|
||||||
|
* No-op when the booking has no paid invoice.
|
||||||
|
*/
|
||||||
|
async markBookingInvoiceRefunded(
|
||||||
|
bookingId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const mg = manager ?? this.dataSource.manager;
|
||||||
|
const invoice = await mg.findOne(Invoice, {
|
||||||
|
where: {
|
||||||
|
source: BOOKING_SOURCE,
|
||||||
|
sourceId: bookingId,
|
||||||
|
status: Freight.InvoiceStatus.Paid,
|
||||||
|
},
|
||||||
|
order: { issuedAt: "DESC" },
|
||||||
|
});
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
await mg.update(
|
||||||
|
Invoice,
|
||||||
|
{ id: invoice.id },
|
||||||
|
{ status: Freight.InvoiceStatus.Refunded },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a booking's open invoice as paid and link the gateway payment.
|
||||||
|
*
|
||||||
|
* Called from `payment.service.finalizePaymentSuccess()` inside its existing DB
|
||||||
|
* transaction (pass the transaction's `EntityManager`). Full-payment only — no
|
||||||
|
* partial settlement in this phase. No-op when the booking has no open invoice.
|
||||||
|
*/
|
||||||
|
async markBookingInvoicePaid(
|
||||||
|
bookingId: string,
|
||||||
|
paymentId: string | null,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const mg = manager ?? this.dataSource.manager;
|
||||||
|
const invoice = await mg.findOne(Invoice, {
|
||||||
|
where: {
|
||||||
|
source: BOOKING_SOURCE,
|
||||||
|
sourceId: bookingId,
|
||||||
|
status: In([Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.Draft, Freight.InvoiceStatus.Overdue]),
|
||||||
|
},
|
||||||
|
order: { issuedAt: "DESC" },
|
||||||
|
});
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
await mg.update(
|
||||||
|
Invoice,
|
||||||
|
{ id: invoice.id },
|
||||||
|
{ status: Freight.InvoiceStatus.Paid, paymentId: paymentId ?? undefined },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single settlement chokepoint for any path that marks a booking PAID: ensure
|
||||||
|
* the booking has an invoice (idempotent generate), then mark it paid. Reused by
|
||||||
|
* the gateway flow and offline/manual "mark paid" so invoicing holds everywhere.
|
||||||
|
*
|
||||||
|
* No-op for bookings that have no invoice and cannot get one (e.g. government
|
||||||
|
* bookings with no company to bill — `generateForBooking` returns null).
|
||||||
|
*/
|
||||||
|
async settleBookingInvoice(
|
||||||
|
bookingId: string,
|
||||||
|
paymentId: string | null = null,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.generateForBooking(bookingId);
|
||||||
|
await this.markBookingInvoicePaid(bookingId, paymentId, manager);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round to 2 decimal places without float drift. */
|
||||||
|
function round2(n: number): number {
|
||||||
|
return Math.round(Number(n) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user