mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 16:40:56 +00:00
fix
This commit is contained in:
@@ -8,7 +8,7 @@ import { BillingService } from "./billing.service";
|
||||
@Controller("billing")
|
||||
@FreightAdmin()
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
constructor(private readonly billingService: BillingService) { }
|
||||
|
||||
@Get("invoices")
|
||||
@ApiOperation({ summary: "List all invoices" })
|
||||
@@ -16,9 +16,9 @@ export class BillingController {
|
||||
return this.billingService.findAll();
|
||||
}
|
||||
|
||||
@Get("invoices/booking/:bookingId")
|
||||
@ApiOperation({ summary: "List invoices for a booking" })
|
||||
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BillingController } from "./billing.controller";
|
||||
import { PortalBillingController } from "./portal-billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentModule } from "../payment/payment.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Invoice])],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||
forwardRef(() => PaymentModule),
|
||||
CompaniesModule,
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
250
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
250
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
/**
|
||||
* Minimal in-memory EntityManager stand-in covering the methods
|
||||
* `generateInvoice` / `markInvoiceAsPaid` 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-1", ...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 makeEvents() {
|
||||
return { emit: jest.fn() };
|
||||
}
|
||||
|
||||
function generateInput(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: "prepaid",
|
||||
companyId: "company-1",
|
||||
companyProfileId: "profile-1",
|
||||
currency: "ETB",
|
||||
lines: [
|
||||
{
|
||||
chargeType: "RAIL_FREIGHT",
|
||||
description: "Rail freight",
|
||||
quantity: 2,
|
||||
unitRate: 500,
|
||||
amount: 1000,
|
||||
},
|
||||
{
|
||||
chargeType: "HAZARD_SURCHARGE",
|
||||
description: "Hazard surcharge",
|
||||
quantity: 2,
|
||||
unitRate: 250,
|
||||
amount: 500,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BillingService.generateInvoice", () => {
|
||||
let savedLines: unknown[];
|
||||
let manager: ReturnType<typeof makeManager>;
|
||||
let events: ReturnType<typeof makeEvents>;
|
||||
let dataSource: { transaction: jest.Mock; manager: unknown };
|
||||
let service: BillingService;
|
||||
|
||||
beforeEach(() => {
|
||||
savedLines = [];
|
||||
manager = makeManager(savedLines);
|
||||
events = makeEvents();
|
||||
dataSource = {
|
||||
transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||
manager,
|
||||
};
|
||||
service = new BillingService(
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a PENDING invoice with one line per input line", async () => {
|
||||
const invoice = await service.generateInvoice(generateInput());
|
||||
|
||||
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
|
||||
expect(invoice.companyId).toBe("company-1");
|
||||
expect(invoice.source).toBe("booking");
|
||||
expect(invoice.sourceId).toBe("booking-1");
|
||||
expect(invoice.totalAmount).toBe(1500);
|
||||
expect(invoice.issuedAt).toBeInstanceOf(Date);
|
||||
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sums line amounts when no explicit totalAmount is given", async () => {
|
||||
const invoice = await service.generateInvoice(
|
||||
generateInput({ totalAmount: undefined }),
|
||||
);
|
||||
expect(invoice.totalAmount).toBe(1500);
|
||||
});
|
||||
|
||||
it("leaves issuedAt null for a DRAFT invoice", async () => {
|
||||
const invoice = await service.generateInvoice(
|
||||
generateInput({ status: Freight.InvoiceStatus.Draft }),
|
||||
);
|
||||
expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
|
||||
expect(invoice.issuedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("enlists in a caller's transaction when a manager is passed", async () => {
|
||||
await service.generateInvoice(generateInput(), manager as never);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.markInvoiceAsPaid", () => {
|
||||
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(open),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.objectContaining({
|
||||
invoiceId: "inv-1",
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paymentId: "pay-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op (no event) when the invoice is already paid", async () => {
|
||||
const paid = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
source: "booking",
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(paid),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.settlePayable", () => {
|
||||
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(open),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"pay-1",
|
||||
mg as never,
|
||||
);
|
||||
|
||||
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op (returns null) when the source has no open invoice", async () => {
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"pay-1",
|
||||
mg as never,
|
||||
);
|
||||
|
||||
expect(settled).toBeNull();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,542 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
|
||||
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||
export interface PayInvoiceOptions {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||
const DEFAULT_DUE_DAYS = 14;
|
||||
|
||||
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Draft,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
/** A single line to bill on a generated invoice. */
|
||||
export interface InvoiceLineInput {
|
||||
chargeType: string;
|
||||
description?: string;
|
||||
/** Units this line bills for; defaults to 1. */
|
||||
quantity?: number;
|
||||
/** Price per unit; defaults to 0. */
|
||||
unitRate?: number;
|
||||
/** Line total; defaults to `quantity * unitRate`. */
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Everything needed to generate an invoice for any source. */
|
||||
export interface GenerateInvoiceInput {
|
||||
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
|
||||
source: Freight.InvoiceSource;
|
||||
/** Identifier of the source record (e.g. booking id). */
|
||||
sourceId: string;
|
||||
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
lines: InvoiceLineInput[];
|
||||
currency?: string;
|
||||
/** Explicit total; defaults to the sum of line amounts. */
|
||||
totalAmount?: number;
|
||||
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
|
||||
dueAt?: Date;
|
||||
dueInDays?: number;
|
||||
/**
|
||||
* Initial status. DRAFT leaves `issuedAt` null; any issued status
|
||||
* (default PENDING) stamps `issuedAt`.
|
||||
*/
|
||||
status?: Freight.InvoiceStatus;
|
||||
}
|
||||
|
||||
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||
export interface InvoiceEventPayload {
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
source: Freight.InvoiceSource;
|
||||
sourceId: string;
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
status: Freight.InvoiceStatus;
|
||||
paymentId?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
private readonly logger = new Logger(BillingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Invoice)
|
||||
private readonly invoicesRepository: Repository<Invoice>,
|
||||
) {}
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly invoices: InvoiceRepository,
|
||||
private readonly invoiceLines: InvoiceLineRepository,
|
||||
private readonly events: EventEmitter2,
|
||||
@Inject(forwardRef(() => PaymentService))
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List every invoice (most recent first). */
|
||||
findAll(): Promise<Invoice[]> {
|
||||
return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
|
||||
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
||||
}
|
||||
|
||||
/** List invoices for a given booking. */
|
||||
findByBooking(bookingId: string): Promise<Invoice[]> {
|
||||
return this.invoicesRepository.find({
|
||||
where: { bookingId },
|
||||
/** 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[] };
|
||||
}
|
||||
|
||||
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
||||
|
||||
/** Resolve the customer's company id from their IAM user id (null if none). */
|
||||
async resolveCompanyId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { company } = await this.companies.getCompanyInfoByUserId(userId);
|
||||
return company?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every invoice billed to a company, newest first, with billing relations. */
|
||||
findByCompany(companyId: string): Promise<Invoice[]> {
|
||||
return this.invoices.findAll({
|
||||
where: { companyId },
|
||||
relations: { company: true, companyProfile: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||
async findForUser(userId: string): Promise<Invoice[]> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
return companyId ? this.findByCompany(companyId) : [];
|
||||
}
|
||||
|
||||
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
async findByIdForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
const invoice = await this.findById(id);
|
||||
if (!companyId || invoice.companyId !== companyId) {
|
||||
throw new NotFoundException(`Invoice ${id} not found`);
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate gateway payment for one of the customer's own invoices. Verifies
|
||||
* ownership, then charges whichever open invoice the source currently has
|
||||
* (see {@link payInvoice}).
|
||||
*/
|
||||
async payInvoiceForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
opts: PayInvoiceOptions = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findByIdForUser(id, userId);
|
||||
return this.payInvoice(
|
||||
invoice.source as Freight.InvoiceSource,
|
||||
invoice.sourceId,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `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")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an invoice for any source (booking, demurrage, manual, …).
|
||||
*
|
||||
* Persists the header plus its lines in one transaction and assigns the next
|
||||
* sequential `invoice_number`. The total defaults to the sum of line amounts
|
||||
* unless `totalAmount` is given. Issued invoices (default PENDING) stamp
|
||||
* `issuedAt`; pass `status: DRAFT` to leave it unissued.
|
||||
*
|
||||
* Pass `manager` to enlist in a caller's transaction (e.g. when generating an
|
||||
* invoice as part of a larger booking flow).
|
||||
*/
|
||||
async generateInvoice(
|
||||
input: GenerateInvoiceInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
private async createInvoice(
|
||||
input: GenerateInvoiceInput,
|
||||
mg: EntityManager,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const currency = input.currency ?? "ETB";
|
||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||
const issued = status !== Freight.InvoiceStatus.Draft;
|
||||
|
||||
const lines = input.lines.map((l) => {
|
||||
const quantity = l.quantity ?? 1;
|
||||
const unitRate = l.unitRate ?? 0;
|
||||
return {
|
||||
chargeType: l.chargeType,
|
||||
description: l.description,
|
||||
quantity,
|
||||
unitRate,
|
||||
amount: l.amount ?? quantity * unitRate,
|
||||
currency: l.currency ?? currency,
|
||||
metadata: l.metadata ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const totalAmount =
|
||||
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||
|
||||
const dueAt =
|
||||
input.dueAt ??
|
||||
new Date(
|
||||
Date.now() +
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||
|
||||
const invoice = await mg.save(
|
||||
mg.create(Invoice, {
|
||||
invoiceNumber,
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
type: input.type,
|
||||
companyId: input.companyId,
|
||||
companyProfileId: input.companyProfileId,
|
||||
totalAmount,
|
||||
currency,
|
||||
status,
|
||||
issuedAt: issued ? new Date() : null,
|
||||
dueAt,
|
||||
}),
|
||||
);
|
||||
|
||||
const savedLines = await Promise.all(
|
||||
lines.map((l) =>
|
||||
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
|
||||
);
|
||||
|
||||
return { ...invoice, lines: savedLines };
|
||||
}
|
||||
|
||||
// ── State transitions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mark an invoice paid and link the gateway payment, then emit
|
||||
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
|
||||
* No-op when the invoice is already paid. Pass `manager` to enlist in a
|
||||
* caller's transaction.
|
||||
*/
|
||||
async markInvoiceAsPaid(
|
||||
invoiceId: string,
|
||||
paymentId: string | null = null,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Paid,
|
||||
"paid",
|
||||
{ paymentId: paymentId ?? undefined },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
||||
* No-op when already refunded.
|
||||
*/
|
||||
async markInvoiceAsRefunded(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Refunded,
|
||||
"refunded",
|
||||
{},
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
|
||||
* No-op when already cancelled.
|
||||
*/
|
||||
async cancelInvoice(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Cancelled,
|
||||
"cancelled",
|
||||
{},
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the invoice, apply the new status (+ extra columns), then emit
|
||||
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
|
||||
* in the target status. Throws when the invoice does not exist.
|
||||
*
|
||||
* Note: the event fires in-process synchronously. When a `manager` from an
|
||||
* outer transaction is passed, listeners run before that transaction commits.
|
||||
*/
|
||||
private async transition(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
event: string,
|
||||
extra: { paymentId?: string },
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
if (invoice.status === status) return invoice;
|
||||
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
this.emitInvoiceEvent(event, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
|
||||
private emitInvoiceEvent(event: string, invoice: Invoice): void {
|
||||
const payload: InvoiceEventPayload = {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
source: invoice.source as Freight.InvoiceSource,
|
||||
sourceId: invoice.sourceId,
|
||||
type: invoice.type,
|
||||
companyId: invoice.companyId,
|
||||
companyProfileId: invoice.companyProfileId,
|
||||
totalAmount: invoice.totalAmount,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status,
|
||||
paymentId: invoice.paymentId ?? null,
|
||||
};
|
||||
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
|
||||
}
|
||||
|
||||
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The invoice a gateway payment should settle for a source record, or null if
|
||||
* none. This is the billing document of record for "what is owed" — callers
|
||||
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
|
||||
* recomputing from the source's own total, so discounts/penalties/adjustments
|
||||
* carried on the invoice are honored.
|
||||
*
|
||||
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
||||
* a booking's up-front vs final charge); omit it to settle whichever single
|
||||
* invoice is currently open. Returns the most recent matching open (unpaid,
|
||||
* non-cancelled) invoice.
|
||||
*/
|
||||
findPayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
type?: string,
|
||||
): Promise<Invoice | null> {
|
||||
return this.dataSource.getRepository(Invoice).findOne({
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a source's currently-open invoice as paid and link the gateway
|
||||
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
|
||||
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
|
||||
* settlement. No-op (returns null) when the source has no open invoice.
|
||||
*
|
||||
* Type-blind by design: settles whichever invoice is due; any per-type reaction
|
||||
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
|
||||
* Pass the caller's transaction `manager` to enlist in its DB transaction.
|
||||
*
|
||||
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
|
||||
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
|
||||
* for callers that settle by source rather than by gateway intent id.
|
||||
*/
|
||||
async settlePayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
paymentId: string | null,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
|
||||
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
|
||||
* No-op (returns null) when the source has no paid invoice.
|
||||
*
|
||||
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
|
||||
* to enlist in its DB transaction.
|
||||
*/
|
||||
async refundPayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsRefunded(invoice.id, mg);
|
||||
}
|
||||
|
||||
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||
|
||||
/**
|
||||
* Charge a source's open invoice through the payment gateway. Billing is the
|
||||
* single place that turns "what is owed" (the invoice) into a payment intent —
|
||||
* the domain never talks to the payment service directly. Resolves the open
|
||||
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on
|
||||
* the invoice (the settlement correlation key), and returns the client action.
|
||||
*
|
||||
* When the provider settles synchronously, the invoice is settled inline here —
|
||||
* after the intent id is stored — so the `payment.succeeded` correlation can
|
||||
* never fire before the link exists. Throws when the source has no open invoice.
|
||||
*/
|
||||
async payInvoice(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
opts: {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
} = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findPayable(source, sourceId);
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: sourceId,
|
||||
source: invoice.source,
|
||||
// Gateway reference type derives from the invoice source by convention
|
||||
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
|
||||
// the domain never supplies it. New sources add their uppercased value to
|
||||
// the PaymentReferenceType enum.
|
||||
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
|
||||
orderRef: invoice.invoiceNumber,
|
||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
payerAccount: opts.payerAccount,
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
|
||||
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||
await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
if (result.immediateSuccess) {
|
||||
await this.settleByPaymentId(
|
||||
result.intentId,
|
||||
result.providerTxnId,
|
||||
result.paidAt,
|
||||
);
|
||||
}
|
||||
|
||||
return result.response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle the open invoice linked to a gateway intent id, if any. Called by the
|
||||
* payment service when an intent succeeds: finds the invoice linked by
|
||||
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
|
||||
* to advance on. Idempotent — no-op when no open invoice is linked (already
|
||||
* settled, or settled inline by {@link payInvoice}).
|
||||
*/
|
||||
async settleByPaymentId(
|
||||
paymentId: string,
|
||||
_providerTxnId?: string,
|
||||
_paidAt?: Date,
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { paymentId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** Gateway options for paying an invoice from the customer portal. */
|
||||
export class PayInvoiceDto {
|
||||
@ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
method?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
platform?: "web" | "mobile";
|
||||
|
||||
@ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
payerAccount?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Browser redirect URL on success." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
returnUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Browser redirect URL on failure." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
failureUrl?: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Invoice } from "./invoice.entity";
|
||||
|
||||
@Entity({ schema: "freight", name: "invoice_lines" })
|
||||
export class InvoiceLine extends BaseEntity {
|
||||
@Column({ name: "invoice_id", type: "uuid", nullable: false })
|
||||
invoiceId!: string;
|
||||
|
||||
@ManyToOne(() => Invoice, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "invoice_id" })
|
||||
invoice!: Invoice;
|
||||
|
||||
@Column({ name: "charge_type", type: "varchar", nullable: false })
|
||||
chargeType!: string;
|
||||
|
||||
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
|
||||
description?: string;
|
||||
|
||||
/** Units this line bills for (e.g. container count, wagon count, tons). */
|
||||
@Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 })
|
||||
quantity!: number;
|
||||
|
||||
/** Price per unit; `amount` is normally `quantity * unitRate`. */
|
||||
@Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
unitRate!: number;
|
||||
|
||||
@Column({
|
||||
name: "amount",
|
||||
type: "numeric",
|
||||
precision: 14,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
})
|
||||
amount!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: "metadata", type: "jsonb", nullable: true })
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -1,17 +1,35 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Column, Entity } from "typeorm";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { PaymentEntity } from "../../payment/entities/payment.entity";
|
||||
import { Company } from "../../companies/entities/company.entity";
|
||||
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
||||
|
||||
@Entity({schema:"freight", name: "invoices" })
|
||||
@Entity({ schema: "freight", name: "invoices" })
|
||||
@Index(["companyId"])
|
||||
@Index(["companyProfileId"])
|
||||
export class Invoice extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
||||
invoiceNumber!: string;
|
||||
|
||||
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
|
||||
amount!: number;
|
||||
/** The customer (company) this invoice is billed to. */
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company?: Company;
|
||||
|
||||
/** The specific company profile (importer/exporter/forwarder/...) billed. */
|
||||
@Column({ name: "company_profile_id", type: "uuid" })
|
||||
companyProfileId!: string;
|
||||
|
||||
@ManyToOne(() => CompanyProfile)
|
||||
@JoinColumn({ name: "company_profile_id" })
|
||||
companyProfile?: CompanyProfile;
|
||||
|
||||
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
|
||||
totalAmount!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
@@ -19,13 +37,38 @@ export class Invoice extends BaseEntity {
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: Freight.PaymentStatus,
|
||||
default: Freight.PaymentStatus.Pending,
|
||||
enum: Freight.InvoiceStatus,
|
||||
default: Freight.InvoiceStatus.Draft,
|
||||
})
|
||||
status!: Freight.PaymentStatus;
|
||||
status!: Freight.InvoiceStatus;
|
||||
|
||||
@Column({ name: "issued_at", type: "timestamptz" })
|
||||
issuedAt!: Date;
|
||||
/** The source of the payment (e.g. booking, customer, etc.). */
|
||||
@Column({ name: "source", type: "varchar", length: 255, nullable: false })
|
||||
source!: string;
|
||||
|
||||
/** The ID of the source (e.g. booking ID, customer ID, etc.). */
|
||||
@Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
|
||||
sourceId!: string;
|
||||
|
||||
/** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
|
||||
@Column({
|
||||
type: "varchar",
|
||||
length: 255,
|
||||
nullable: false,
|
||||
})
|
||||
type!: string;
|
||||
|
||||
/** Set when the invoice is actually issued (DRAFT invoices leave this null). */
|
||||
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
|
||||
issuedAt?: Date | null;
|
||||
|
||||
/** The ID of the payment that generated this invoice. */
|
||||
@Column({ name: "payment_id", type: "uuid", nullable: true })
|
||||
paymentId?: string | null;
|
||||
|
||||
@ManyToOne(() => PaymentEntity)
|
||||
@JoinColumn({ name: "payment_id" })
|
||||
payment?: PaymentEntity;
|
||||
|
||||
@Column({ name: "due_at", type: "timestamptz" })
|
||||
dueAt!: Date;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceLineRepository extends BaseRepository<InvoiceLine> {
|
||||
constructor(
|
||||
@InjectRepository(InvoiceLine) repository: Repository<InvoiceLine>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceRepository extends BaseRepository<Invoice> {
|
||||
constructor(
|
||||
@InjectRepository(Invoice) repository: Repository<Invoice>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
|
||||
/**
|
||||
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||
* org-wide), every route here is force-scoped to the signed-in customer's
|
||||
* company — they only ever see and pay their own invoices.
|
||||
*/
|
||||
@ApiTags("billing")
|
||||
@ApiBearerAuth()
|
||||
@Controller("billing")
|
||||
export class PortalBillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
@Get("my-invoices")
|
||||
@ApiOperation({ summary: "List the signed-in customer's invoices" })
|
||||
findMine(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.billingService.findForUser(resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get("my-invoices/:id")
|
||||
@ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
|
||||
findMineById(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post("my-invoices/:id/pay")
|
||||
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
|
||||
pay(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: PayInvoiceDto,
|
||||
) {
|
||||
return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
|
||||
method: dto.method,
|
||||
platform: dto.platform ?? "web",
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl,
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
GenerateInvoiceInput,
|
||||
InvoiceEventPayload,
|
||||
InvoiceLineInput,
|
||||
} from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FirstMileService } from '../first-mile/first-mile.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
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, which type to use, and how it advances when paid. 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,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => FirstMileService))
|
||||
private readonly firstMile: FirstMileService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatch: BookingBatchService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 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): each invoice type advances
|
||||
* the booking its own way. Only PREPAID exists today.
|
||||
*/
|
||||
@OnEvent('booking.invoice.paid')
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
switch (payload.type) {
|
||||
case Freight.InvoiceType.Prepaid:
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||
* and is allocated into its batch. Idempotent — no-op when already PAID.
|
||||
*
|
||||
* General contracts are a separate aggregate now: their CONTRACT_ACTIVE
|
||||
* lifecycle and ordering window live in the contracts module, advanced by the
|
||||
* contract transition/clearance services — not by booking payment. Every
|
||||
* booking that settles here is a ONE_TIME shipment, so there is no contract
|
||||
* branch (legacy GENERAL_CONTRACT booking creation now 410s).
|
||||
*/
|
||||
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
|
||||
return;
|
||||
}
|
||||
if (booking.paymentStatus === 'PAID') return;
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: bookingId },
|
||||
{ paymentStatus: 'PAID', status: 'PAID' },
|
||||
);
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
} from "../payment/payments.dto";
|
||||
|
||||
/**
|
||||
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
|
||||
* booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands
|
||||
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
|
||||
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
|
||||
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
|
||||
*/
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
export class BookingPaymentController {
|
||||
constructor(private readonly billing: BillingService) { }
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for a freight booking",
|
||||
description: "Charges the booking's open invoice through the payment gateway.",
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
|
||||
method: dto.method,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl,
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||
}
|
||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.billing.payInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
{ method, platform },
|
||||
);
|
||||
const url =
|
||||
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
||||
|
||||
if (url) {
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
||||
}
|
||||
return res
|
||||
.status(HttpStatus.OK)
|
||||
.type("html")
|
||||
.send(this.buildStatusHtml(result.status, result.intentId));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
||||
}
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||
<title>Redirecting to payment…</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
p { color: #555; margin: 0 0 16px; }
|
||||
a { color: #1a73e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="spinner"></div>
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment status</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
small { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status">${status}</div>
|
||||
<small>Intent: ${intentId}</small>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment error</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||
p { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="error">Payment could not be initiated</div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,37 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Freight } from '@edr/types';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { PaymentService } from '../payment/payment.service';
|
||||
import { PaymentStatus } from '../payment/entities/payment.entity';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
||||
"action-required",
|
||||
"processing",
|
||||
"success",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly paymentService: PaymentService,
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Start payment for a booking. The booking never touches the payment gateway
|
||||
* directly — it charges its invoice through billing, which resolves the amount
|
||||
* and drives the provider. Returns the provider redirect URL (empty when none).
|
||||
*/
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||
|
||||
const existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
if (existing.clientAction) {
|
||||
const action = existing.clientAction as { type?: string; url?: string };
|
||||
if (action.type === "REDIRECT" && action.url) {
|
||||
return { redirectUrl: action.url };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await this.paymentService.initiatePayment({
|
||||
bookingId,
|
||||
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
|
||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||
platform: "web",
|
||||
platform: 'web',
|
||||
});
|
||||
|
||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||
return {
|
||||
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
|
||||
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,6 +33,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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -10,7 +10,11 @@ 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 { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
import { BookingPaymentController } from './booking-payment.controller';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
@@ -35,7 +39,6 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
|
||||
@Module({
|
||||
@@ -51,7 +54,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BookingContractSignature,
|
||||
BookingContainerAllocation,
|
||||
]),
|
||||
PaymentModule,
|
||||
BillingModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
@@ -66,7 +70,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [BookingsController, BookingAllocationController, PayController],
|
||||
controllers: [BookingsController, PayController, BookingPaymentController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
@@ -75,6 +79,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BookingPricingService,
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingInvoiceService,
|
||||
BookingPaymentService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
@@ -82,6 +87,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -308,10 +308,23 @@ export class BookingsService {
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
if (!dto.governmentInstitution?.trim()) {
|
||||
throw new BadRequestException('governmentInstitution is required for government bookings');
|
||||
// Government bookings bill to a real seeded government company + an
|
||||
// explicitly-chosen importer/exporter profile (no more null company +
|
||||
// free-text institution).
|
||||
if (!dto.companyId) {
|
||||
throw new BadRequestException('A government company is required for government bookings');
|
||||
}
|
||||
companyId = dto.companyId ?? null;
|
||||
const govCompany = await this.companiesService.findCompanyById(dto.companyId);
|
||||
if (govCompany.kind !== CompanyKind.Government) {
|
||||
throw new BadRequestException('Selected company is not a government entity');
|
||||
}
|
||||
if (govCompany.status !== CompanyStatus.Active) {
|
||||
throw new BadRequestException('Selected government company is not active');
|
||||
}
|
||||
if (!dto.companyProfileId) {
|
||||
throw new BadRequestException('A government company profile is required for government bookings');
|
||||
}
|
||||
companyId = govCompany.id;
|
||||
} else if (!companyId) {
|
||||
if (!userId) {
|
||||
throw new BadRequestException(
|
||||
@@ -384,7 +397,16 @@ export class BookingsService {
|
||||
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
||||
// for non-government bookings with a resolved company; never blocks creation.
|
||||
let companyProfileId: string | null = null;
|
||||
if (!isGovernment && companyId) {
|
||||
if (dto.companyProfileId && companyId) {
|
||||
// Explicit profile pin (government booking, or staff booking on behalf):
|
||||
// must belong to the chosen company and be active.
|
||||
const profile =
|
||||
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||
companyId,
|
||||
dto.companyProfileId,
|
||||
);
|
||||
companyProfileId = profile.id;
|
||||
} else if (companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
@@ -414,6 +436,16 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Every booking must link to a company and a company profile.
|
||||
if (!companyId) {
|
||||
throw new BadRequestException('A company is required to create a booking');
|
||||
}
|
||||
if (!companyProfileId) {
|
||||
throw new BadRequestException(
|
||||
'A company profile is required to create a booking — none could be resolved for this company',
|
||||
);
|
||||
}
|
||||
|
||||
const needsConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.needsConsolidation(containers)
|
||||
@@ -444,10 +476,10 @@ export class BookingsService {
|
||||
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId: companyId ?? null,
|
||||
companyId,
|
||||
companyProfileId,
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
governmentInstitution: dto.governmentInstitution?.trim() || null,
|
||||
trainId: dto.trainId,
|
||||
trainScheduleId: dto.trainScheduleId ?? null,
|
||||
contractType: dto.contractType,
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
Validate,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
@@ -104,19 +103,31 @@ export class CreateBookingDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isGovernment?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
||||
@ValidateIf((o) => o.isGovernment === true)
|
||||
/** @deprecated Government bookings now bill to a real government company. */
|
||||
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
governmentInstitution?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||
@ValidateIf((o) => o.isGovernment !== true)
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyProfileId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@@ -106,8 +106,10 @@ export class Booking extends BaseEntity {
|
||||
// @JoinColumn({ name: 'customer_id' })
|
||||
// customer?: Customer;
|
||||
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
// Every booking is billed to a company — government bookings bill to a seeded
|
||||
// government company (companies.kind = 'government'). Enforced NOT NULL.
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
@@ -117,11 +119,12 @@ export class Booking extends BaseEntity {
|
||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
||||
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
|
||||
* legacy/government/staff-created bookings.
|
||||
* Customer portal lists and dashboard KPIs are scoped by this. Required:
|
||||
* commercial bookings resolve it from trade direction / active mode;
|
||||
* government bookings carry the explicitly-picked government profile.
|
||||
*/
|
||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||
companyProfileId?: string | null;
|
||||
@Column({ name: 'company_profile_id', type: 'uuid' })
|
||||
companyProfileId!: string;
|
||||
|
||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||
@JoinColumn({ name: 'company_profile_id' })
|
||||
|
||||
@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
async findPaginated(
|
||||
query: ListCompaniesQueryDto,
|
||||
): Promise<{ items: Company[]; total: number }> {
|
||||
const { page = 1, pageSize = 20, search, type, status } = query;
|
||||
const { page = 1, pageSize = 20, search, type, kind, status } = query;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('company')
|
||||
@@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
qb.andWhere('company.type = :type', { type });
|
||||
}
|
||||
|
||||
if (kind) {
|
||||
qb.andWhere('company.kind = :kind', { kind });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
qb.andWhere('company.status = :status', { status });
|
||||
}
|
||||
|
||||
@@ -337,6 +337,29 @@ export class CompaniesService {
|
||||
return company;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||
* to the booking's company and be Active. Used for government bookings (staff
|
||||
* pick the profile) and any staff booking that pins a profile directly.
|
||||
*/
|
||||
async getActiveCompanyProfileForBooking(
|
||||
companyId: string,
|
||||
profileId: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const profile = await this.companyProfilesRepo.findById(profileId);
|
||||
if (!profile || profile.companyId !== companyId) {
|
||||
throw new BadRequestException(
|
||||
"Selected company profile does not belong to the chosen company",
|
||||
);
|
||||
}
|
||||
if (profile.status !== ProfileStatus.Active) {
|
||||
throw new BadRequestException(
|
||||
"Selected company profile is not active",
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async getCompanyInfoByUserId(
|
||||
userId: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
import { CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||
|
||||
export class ListCompaniesQueryDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@@ -28,6 +28,11 @@ export class ListCompaniesQueryDto {
|
||||
@IsIn(Object.values(CompanyType))
|
||||
type?: CompanyType;
|
||||
|
||||
@ApiPropertyOptional({ enum: CompanyKind })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(CompanyKind))
|
||||
kind?: CompanyKind;
|
||||
|
||||
@ApiPropertyOptional({ enum: CompanyStatus })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(CompanyStatus))
|
||||
|
||||
@@ -10,6 +10,16 @@ export enum CompanyType {
|
||||
Transporter = "transporter",
|
||||
}
|
||||
|
||||
/**
|
||||
* Sector of the company — orthogonal to {@link CompanyType} (the trade role).
|
||||
* Government bookings are billed to a single seeded `GOVERNMENT` company instead
|
||||
* of carrying a null company + free-text institution.
|
||||
*/
|
||||
export enum CompanyKind {
|
||||
Commercial = "commercial",
|
||||
Government = "government",
|
||||
}
|
||||
|
||||
export enum CompanyStatus {
|
||||
Active = "active",
|
||||
Pending = "pending",
|
||||
@@ -25,6 +35,7 @@ export enum CompanyNationality {
|
||||
@Entity({ schema: "freight", name: "companies" })
|
||||
@Index(["tin"])
|
||||
@Index(["type"])
|
||||
@Index(["kind"])
|
||||
export class Company extends BaseEntity {
|
||||
@Column({ name: "name", type: "varchar", length: 200 })
|
||||
name!: string;
|
||||
@@ -32,6 +43,16 @@ export class Company extends BaseEntity {
|
||||
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
||||
type!: CompanyType;
|
||||
|
||||
/** Commercial customer vs. the seeded government entity. */
|
||||
@Column({
|
||||
name: "kind",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
default: CompanyKind.Commercial,
|
||||
enum: CompanyKind,
|
||||
})
|
||||
kind!: CompanyKind;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -11,6 +12,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
@@ -45,6 +47,8 @@ export interface CreateBookingUnderContractResult {
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractBookingService {
|
||||
private readonly logger = new Logger(ContractBookingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
@@ -52,6 +56,7 @@ export class ContractBookingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -199,6 +204,22 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
|
||||
// Contract bookings are born past the billable gate (the contract is already
|
||||
// executed), so the invoice is generated here — they never pass through the
|
||||
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
|
||||
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
|
||||
// Skips silently when unbillable (no company / no priced amount).
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(result ?? booking)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for contract booking ${booking.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat
|
||||
import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
|
||||
|
||||
type PaymentType = "booking"
|
||||
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||
type PaymentType = string
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||
type Currency = "ETB" | "USD"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
@@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: 'varchar', length: 255, name: "ref_id" })
|
||||
refId!: string
|
||||
|
||||
@Column({ type: "enum", enum: ["booking"] })
|
||||
@Column({ type: "varchar", length: 50 })
|
||||
type!: PaymentType;
|
||||
|
||||
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
|
||||
referenceType?: string;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
Body,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
@@ -20,14 +20,7 @@ import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { IntentStatusDto, RefundDto } from "./payments.dto";
|
||||
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
@@ -73,16 +66,6 @@ export class PaymentController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for a freight booking",
|
||||
description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`,
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiatePayment(@Body() dto: InitiatePaymentDto) {
|
||||
return this.paymentService.initiatePayment(dto);
|
||||
}
|
||||
|
||||
@Get("intents/:bookingId")
|
||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||
@ApiOkResponse({ type: IntentStatusDto })
|
||||
@@ -97,54 +80,6 @@ export class PaymentController {
|
||||
return this.paymentService.refund(dto);
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||
}
|
||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.paymentService.initiatePayment({ bookingId, method, platform });
|
||||
const url =
|
||||
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
||||
|
||||
if (url) {
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
||||
}
|
||||
return res
|
||||
.status(HttpStatus.OK)
|
||||
.type("html")
|
||||
.send(this.buildStatusHtml(result.status, result.intentId));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
||||
}
|
||||
}
|
||||
|
||||
@Get("receipt/:orderId")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||
@@ -153,76 +88,4 @@ export class PaymentController {
|
||||
const html = await this.paymentService.genReceiptHtml(orderId);
|
||||
return res.status(HttpStatus.OK).type("html").send(html);
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||
<title>Redirecting to payment…</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
p { color: #555; margin: 0 0 16px; }
|
||||
a { color: #1a73e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="spinner"></div>
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment status</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
small { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status">${status}</div>
|
||||
<small>Intent: ${intentId}</small>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment error</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||
p { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="error">Payment could not be initiated</div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
|
||||
import { DynamicModule, forwardRef, Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
} from "@edr/types";
|
||||
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
|
||||
import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
@@ -58,9 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
DropdownSettingsModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => BillingModule),
|
||||
TypeOrmModule.forFeature([
|
||||
PaymentEntity,
|
||||
PaymentWebhookEventEntity,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DataSource } from "typeorm";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
@@ -28,13 +29,44 @@ import {
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentPlatformDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
|
||||
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
||||
* the caller (billing) — this service never derives them from a domain record. */
|
||||
export interface InitiateIntentInput {
|
||||
/** Opaque domain reference (booking id, …). */
|
||||
referenceId: string;
|
||||
/** Invoice source that owns the intent ('booking', …) — stored on the projection. */
|
||||
source: string;
|
||||
/** Gateway reference type the intent is opened with (caller's domain decides it). */
|
||||
referenceType: PaymentReferenceType;
|
||||
/** Human-readable order ref shown on provider pages. */
|
||||
orderRef: string;
|
||||
/** Authoritative amount in minor units, computed by the caller. */
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
/** Stored on the intent projection for receipts/dashboards. */
|
||||
reason?: string;
|
||||
/** Provider/method selector. */
|
||||
method: ProviderMethod | string;
|
||||
platform?: PaymentPlatformDto;
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
export interface InitiateIntentResult {
|
||||
intentId: string;
|
||||
response: InitiateResponseDto;
|
||||
/** True when the provider settled the charge synchronously during initiate. */
|
||||
immediateSuccess: boolean;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
@@ -45,6 +77,23 @@ const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"refunded": ProviderPaymentStatus.CANCELLED,
|
||||
};
|
||||
|
||||
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
TELEBIRR: "telebirr",
|
||||
CBE_BIRR: "cbe-birr",
|
||||
EBIRR: "ebirr",
|
||||
WAAFI: "waafi",
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and
|
||||
* NOTHING domain-specific: it never loads a booking, computes an amount, or
|
||||
* advances a domain record. On settlement it notifies billing directly
|
||||
* ({@link BillingService.settleByPaymentId}); billing (and through it, the domain)
|
||||
* reacts. The billing↔payment pair is a deliberate forwardRef cycle.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentService {
|
||||
private readonly logger = new Logger(PaymentService.name);
|
||||
@@ -53,9 +102,8 @@ export class PaymentService {
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly firstMileService: FirstMileService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
async getAll(filters: {
|
||||
@@ -123,7 +171,6 @@ export class PaymentService {
|
||||
total += row.count;
|
||||
}
|
||||
|
||||
// Sum of successfully collected amounts.
|
||||
const paidAgg = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
||||
@@ -141,69 +188,74 @@ export class PaymentService {
|
||||
};
|
||||
}
|
||||
|
||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: dto.bookingId });
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const amountMinor = Math.round(Number(booking.totalAmount));
|
||||
|
||||
/**
|
||||
* Open a gateway intent for a caller-supplied amount/reference and project it
|
||||
* locally. Returns the intent id (so billing can correlate the invoice) plus
|
||||
* the client action. When the provider settles synchronously, the intent is
|
||||
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
|
||||
* has stored the intent id, avoiding a settle-before-correlation race.
|
||||
*/
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: booking.id,
|
||||
orderRef: booking.reference,
|
||||
amountMinor,
|
||||
currency: booking.paymentCurrency,
|
||||
provider: dto.method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
||||
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
||||
referenceType: input.referenceType,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
|
||||
await this.datasource.getRepository(Booking).update(
|
||||
{ id: dto.bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||
const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
// avoiding a settle-before-correlation race.
|
||||
await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
paidAt,
|
||||
notify: false,
|
||||
});
|
||||
}
|
||||
|
||||
return this.formatIntentResponse(intent);
|
||||
return {
|
||||
intentId: intent.id,
|
||||
// `intent` still reflects the projection status ("processing" on immediate
|
||||
// success — settlement is applied by the caller, not shown synchronously).
|
||||
response: this.formatIntentResponse(intent),
|
||||
immediateSuccess,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async syncIntentProjection(
|
||||
bookingId: string,
|
||||
booking: Booking,
|
||||
/** Create or update the local intent projection from a provider snapshot. */
|
||||
private async upsertIntent(
|
||||
input: InitiateIntentInput,
|
||||
snapshot: PaymentIntentSnapshot,
|
||||
): Promise<PaymentEntity> {
|
||||
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
const existing = await this.paymentRepo.findOneBy({
|
||||
refId: input.referenceId,
|
||||
});
|
||||
|
||||
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
TELEBIRR: "telebirr",
|
||||
CBE_BIRR: "cbe-birr",
|
||||
EBIRR: "ebirr",
|
||||
WAAFI: "waafi",
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
};
|
||||
const method: PaymentEntity["method"] =
|
||||
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
||||
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? "processing"
|
||||
: this.toLocalStatus(snapshot.status);
|
||||
const status =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? "processing"
|
||||
: this.toLocalStatus(snapshot.status);
|
||||
|
||||
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
|
||||
const clientAction = (snapshot.clientAction ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const data = {
|
||||
status,
|
||||
method,
|
||||
@@ -220,30 +272,37 @@ export class PaymentService {
|
||||
}
|
||||
|
||||
return this.paymentRepo.create({
|
||||
refId: bookingId,
|
||||
type: "booking",
|
||||
amount: booking.totalAmount,
|
||||
currency: booking.paymentCurrency,
|
||||
reason: `Payment for booking ${booking.reference}`,
|
||||
refId: input.referenceId,
|
||||
type: input.source,
|
||||
referenceType: input.referenceType,
|
||||
amount: input.amountMinor,
|
||||
currency: input.currency as PaymentEntity["currency"],
|
||||
reason: input.reason ?? `Payment for ${input.orderRef}`,
|
||||
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
||||
clientAction: clientAction ?? {},
|
||||
...data,
|
||||
} as any);
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
/**
|
||||
* Reconcile an intent's status with the gateway by reference. Read-only on the
|
||||
* domain side: it syncs the local projection and, when the provider reports a
|
||||
* newly-observed success, notifies billing to settle. `referenceId` is opaque
|
||||
* (the booking id, but this service does not load it).
|
||||
*/
|
||||
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ refId: referenceId });
|
||||
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
bookingId,
|
||||
(local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT,
|
||||
referenceId,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
|
||||
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,75 +310,54 @@ export class PaymentService {
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: bookingId });
|
||||
// Sync local projection with provider-reported status.
|
||||
const becameSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success";
|
||||
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
if (becameSuccess) {
|
||||
await this.markIntentSucceeded(local.id, {
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
} else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.paymentRepo.update(
|
||||
{ id: local.id },
|
||||
{
|
||||
status: this.toLocalStatus(snapshot.status),
|
||||
failerCode: snapshot.failureCode ?? undefined,
|
||||
failureMessage: snapshot.failureMessage ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
|
||||
return this.formatIntentStatus(refreshed ?? intent);
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
||||
return this.formatIntentStatus(refreshed ?? local);
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
||||
if (!intent || intent.status !== "success") {
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
}
|
||||
/**
|
||||
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
||||
* when the caller settles inline and will trigger settlement itself.
|
||||
*/
|
||||
async markIntentSucceeded(
|
||||
intentId: string,
|
||||
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
|
||||
): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === "success") return { alreadyFinalized: true };
|
||||
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
||||
});
|
||||
const paidAt = opts.paidAt ?? new Date();
|
||||
await this.paymentRepo.update(
|
||||
{ id: intent.id },
|
||||
{ status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId },
|
||||
);
|
||||
|
||||
return { refunded: true, bookingId: dto.bookingId };
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
bookingId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
// const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
// if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
// if (intent.status === "success") return { alreadyFinalized: true };
|
||||
|
||||
// Every booking is a real shipment now (contracts are a separate aggregate),
|
||||
// so payment always settles the booking to PAID and enters allocation.
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
// await mg.update(
|
||||
// PaymentEntity,
|
||||
// // { id: intent.id },
|
||||
// {id:input.intentId},
|
||||
// { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
||||
// );
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: input.bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
if (opts.notify !== false) {
|
||||
await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt);
|
||||
}
|
||||
|
||||
return { alreadyFinalized: false };
|
||||
@@ -338,6 +376,29 @@ export class PaymentService {
|
||||
{ id: intent.id },
|
||||
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
|
||||
);
|
||||
|
||||
// Invoice stays open for retry — nothing to settle. Logged only.
|
||||
this.logger.warn(
|
||||
`Payment ${intent.id} failed for ${intent.refId}` +
|
||||
(input.failureMessage ? `: ${input.failureMessage}` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
||||
if (!intent || intent.status !== "success") {
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
}
|
||||
|
||||
// NOTE: refunding still mutates the booking directly — left intact pending
|
||||
// the refund redesign. TODO: route refunds through billing.refundPayable +
|
||||
// a `${source}.invoice.refunded` reaction, like settlement.
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
||||
});
|
||||
|
||||
return { refunded: true, bookingId: dto.bookingId };
|
||||
}
|
||||
|
||||
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||
@@ -366,7 +427,7 @@ export class PaymentService {
|
||||
}
|
||||
|
||||
findBookingById(id: string) {
|
||||
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
|
||||
return this.paymentRepo.findOneBy({ refId: id });
|
||||
}
|
||||
|
||||
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||
@@ -401,46 +462,34 @@ export class PaymentService {
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId:event.intentId,
|
||||
bookingId: event.referenceId,
|
||||
if (event.eventType === "payment.succeeded") {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
|
||||
if (!intent) {
|
||||
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||
}
|
||||
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
return { processed: true, alreadyFinalized };
|
||||
// console.log(`Received payment event: ${JSON.stringify(event)}`);
|
||||
// if (event.eventType === "payment.succeeded") {
|
||||
// console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`);
|
||||
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
// if (!intent) {
|
||||
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
// }
|
||||
// console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`);
|
||||
// const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
// intentId: intent.id,
|
||||
// bookingId: event.referenceId,
|
||||
// providerTxnId: event.providerTxnId,
|
||||
// paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
// });
|
||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
// return { processed: true, alreadyFinalized };
|
||||
// }
|
||||
}
|
||||
|
||||
// if (event.eventType === "payment.failed") {
|
||||
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
// if (!intent) {
|
||||
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
// }
|
||||
// await this.markPaymentFailed({
|
||||
// intentId: intent.id,
|
||||
// failureCode: event.failureCode,
|
||||
// failureMessage: event.failureMessage,
|
||||
// });
|
||||
// return { processed: true };
|
||||
// }
|
||||
if (event.eventType === "payment.failed") {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
|
||||
if (!intent) {
|
||||
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||
}
|
||||
await this.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: event.failureCode,
|
||||
failureMessage: event.failureMessage,
|
||||
});
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
// return { processed: false, reason: `Unknown event type: ${event.eventType}` };
|
||||
return { processed: false, reason: `Unknown event type: ${event.eventType}` };
|
||||
}
|
||||
|
||||
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {
|
||||
|
||||
Reference in New Issue
Block a user