refactor(payment): centralize invoice processing with detailed tracking and configurable logging

This commit is contained in:
ghost2023
2026-07-02 11:47:35 +03:00
parent 118ba46930
commit 1c012ce1e2
11 changed files with 107 additions and 248 deletions

View File

@@ -118,6 +118,8 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
migrationsRun: true,
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
logging: process.env.NODE_ENV === "development",
logging: process.env.DB_LOG
? process.env.DB_LOG === "true"
: process.env.NODE_ENV === "development",
};
});

View File

@@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller";
import { PaymentController } from "./payment.controller";
import { BillingService } from "./billing.service";
import { DocumentsModule } from "./documents/documents.module";
import { Invoice } from "./entities/invoice.entity";
@@ -19,7 +20,7 @@ import { CompaniesModule } from "../companies/companies.module";
CompaniesModule,
DocumentsModule,
],
controllers: [BillingController, PortalBillingController],
controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService],
})

View File

@@ -116,12 +116,14 @@ describe("BillingService.generateInvoice", () => {
});
describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
@@ -143,7 +145,13 @@ describe("BillingService.markInvoiceAsPaid", () => {
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
{
status: Freight.InvoiceStatus.Paid,
paymentId: "pay-1",
paidAt: expect.any(Date),
paidAmount: 1500,
balanceAmount: 0,
},
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
@@ -265,74 +273,3 @@ describe("BillingService.recordPayment", () => {
expect(mg.update).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
{} as never, // invoiceDocuments
);
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
{} as never, // invoiceDocuments
);
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();
});
});

View File

@@ -49,7 +49,6 @@ 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.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
@@ -285,20 +284,15 @@ export class BillingService {
/**
* 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}).
* ownership, then charges the invoice directly by ID (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,
);
await this.findByIdForUser(id, userId);
return this.payInvoice(id, opts);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
@@ -422,23 +416,39 @@ export class BillingService {
// ── 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.
* Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts,
* 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,
);
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 === Freight.InvoiceStatus.Paid) return invoice;
await mg.update(Invoice, { id: invoiceId }, {
status: Freight.InvoiceStatus.Paid,
paymentId,
paidAt: invoice.paidAt ?? new Date(),
paidAmount: invoice.totalAmount,
balanceAmount: 0,
} as never);
const updated = {
...invoice,
status: Freight.InvoiceStatus.Paid,
paymentId,
paidAt: invoice.paidAt ?? new Date(),
paidAmount: invoice.totalAmount,
balanceAmount: 0,
} as Invoice;
this.emitInvoiceEvent("paid", updated);
return updated;
}
/**
@@ -628,56 +638,24 @@ export class BillingService {
}
/**
* 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.
* 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.
*/
async settlePayable(
findInvoice(
source: Freight.InvoiceSource,
sourceId: string,
paymentId: string | null,
manager?: EntityManager,
type?: string,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
return this.dataSource.getRepository(Invoice).findOne({
where: {
source,
sourceId,
...(type ? { type } : {}),
},
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);
}
/**
@@ -693,11 +671,17 @@ export class BillingService {
async expirePayable(
source: Freight.InvoiceSource,
sourceId: string,
type?: string,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
@@ -721,11 +705,17 @@ export class BillingService {
source: Freight.InvoiceSource,
sourceId: string,
dueAt: Date,
type?: string,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
@@ -749,19 +739,20 @@ export class BillingService {
// ── 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.
* Charge an 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 invoice by ID,
* opens an intent for `invoice.balanceAmount` (so partial payments are honored),
* 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.
* never fire before the link exists. Throws when the invoice is not found or
* not in an open/payable status.
*/
async payInvoice(
source: Freight.InvoiceSource,
sourceId: string,
invoiceId: string,
opts: {
method?: string;
platform?: "web" | "mobile";
@@ -770,15 +761,17 @@ export class BillingService {
failureUrl?: string;
} = {},
): Promise<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId);
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId, status: In(OPEN_STATUSES) },
});
if (!invoice) {
throw new NotFoundException(
`No open invoice to charge for ${source}:${sourceId}`,
`Invoice ${invoiceId} not found or not in a payable status`,
);
}
const result = await this.payment.initiate({
referenceId: sourceId,
referenceId: invoice.sourceId,
source: invoice.source,
// Freight payments settle under the generic SHIPMENT reference — how the
// payment service attributes them to the freight API. The payment ↔ invoice
@@ -787,7 +780,7 @@ export class BillingService {
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)),
amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",

View File

@@ -16,9 +16,8 @@ import {
} 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 { BillingService } from "./billing.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
@@ -27,25 +26,24 @@ import {
} 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.
* Central payment entrypoints. Domain-agnostic the caller supplies an
* invoice ID and the billing service resolves the amount and drives the
* gateway. The domain never talks to the payment service directly.
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
*/
@ApiTags("Payment")
@Controller("payments")
export class BookingPaymentController {
export class PaymentController {
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.",
summary: "Initiate payment for an invoice",
description: "Charges the invoice through the payment gateway.",
})
@ApiOkResponse({ type: InitiateResponseDto })
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
return this.billing.payInvoice(dto.invoiceId, {
method: dto.method,
platform: dto.platform,
payerAccount: dto.payerAccount,
@@ -59,23 +57,23 @@ export class BookingPaymentController {
@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.",
"Charges the 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: "invoiceId", 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("invoiceId") invoiceId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!bookingId) {
if (!invoiceId) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
.send(this.buildErrorHtml("Missing required query parameter: invoiceId"));
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res
@@ -86,8 +84,7 @@ export class BookingPaymentController {
try {
const result = await this.billing.payInvoice(
Freight.InvoiceSource.Booking,
bookingId,
invoiceId,
{ method, platform },
);
const url =

View File

@@ -1,43 +0,0 @@
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 { BillingService } from '../billing/billing.service';
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
@Injectable()
export class BookingPaymentService {
constructor(
private readonly bookingsRepository: BookingsRepository,
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 resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
method: PaymentMethodTypeEnum.TELEBIRR,
platform: 'web',
});
const action = resp.clientAction as { type?: string; url?: string } | undefined;
return {
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
};
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
return booking;
}
}

View File

@@ -447,6 +447,7 @@ export class BookingTransitionService {
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"CONTRACT_READY",
"OPERATION_REQUEST_PENDING",
]);
await this.bookingsRepository.createReviewNote(

View File

@@ -14,13 +14,10 @@ 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";
import { BookingTransitionService } from "./booking-transition.service";
import { BookingsController } from "./bookings.controller";
import { PayController } from "./pay.controller";
import { BookingsRepository } from "./bookings.repository";
import { ConsolidationService } from "./consolidation.service";
import { BookingsService } from "./bookings.service";
@@ -69,7 +66,7 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
}),
],
controllers: [BookingsController, PayController, BookingPaymentController],
controllers: [BookingsController],
providers: [
BookingsService,
BookingsRepository,
@@ -79,7 +76,6 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
BookingPaymentService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,

View File

@@ -1,27 +0,0 @@
import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingPaymentService } from './booking-payment.service';
// import { BookingTransitionService } from './booking-transition.service';
// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
// import { Booking } from './entities/booking.entity';
// import { BookingNextStep } from './booking-next-step.util';
@ApiTags('payments')
@ApiBearerAuth()
@Controller('bookings')
export class PayController {
constructor(
private readonly paymentService: BookingPaymentService,
// private readonly transitionService: BookingTransitionService,
) { }
@Post(':id/payment/pay')
@ApiOperation({ summary: 'Complete in-app payment (mock)' })
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
async pay(@Param('id', ParseUUIDPipe) id: string) {
return await this.paymentService.pay(id);
// const abstract = await this.transitionService.enrichBookingResponse(booking);
// return { ...abstract, paymentReceipt: receipt };
}
}

View File

@@ -15,9 +15,9 @@ export enum PaymentMethodTypeEnum {
}
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" })
@ApiProperty({ example: "invoice-uuid" })
@IsString()
bookingId!: string;
invoiceId!: string;
@ApiProperty({
enum: PaymentMethodTypeEnum,

View File

@@ -1069,6 +1069,7 @@ export class BookingBatchService implements OnModuleInit {
Freight.InvoiceSource.Booking,
booking.id,
deadline,
"PREPAID",
);
await this.notifier.payNow(booking, deadline);
}
@@ -1120,7 +1121,7 @@ export class BookingBatchService implements OnModuleInit {
// Pay window closed before settlement → expire the booking's open invoice too
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
// source-agnostic.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id);
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
this.notifier.expired(booking);
}
@@ -1167,6 +1168,7 @@ export class BookingBatchService implements OnModuleInit {
await this.billing.expirePayable(
Freight.InvoiceSource.Booking,
victim.id,
"PREPAID",
manager,
);
});