feat: rewired up the billing and payment with the booking

This commit is contained in:
Nathnael
2026-06-29 09:25:46 +00:00
parent 9cd24d9b51
commit 4be4286fbf
12 changed files with 584 additions and 380 deletions

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
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,
@@ -9,7 +10,11 @@ import {
InvoiceLineInput,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
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. */
@@ -22,18 +27,32 @@ interface StoredPricingBreakdown {
/** Round to 2 decimals, avoiding binary float drift. */
const round2 = (n: number): number => Math.round(n * 100) / 100;
/** Setting code holding the general-contract ordering window (months). */
const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
/**
* Owns the booking ⇄ invoice mapping — the one place that knows how a booking
* turns into invoices and which type to use. Bookings are the billable business
* entity, so they generate their own invoices directly via {@link BillingService}
* (billing stays source-agnostic). All booking-specific type branching lives here,
* at the two points it belongs: invoice creation and settlement (the paid handler).
* 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) {}
constructor(
private readonly billing: BillingService,
private readonly bookingsRepository: BookingsRepository,
private readonly dataSource: DataSource,
private readonly dropdownSettings: DropdownSettingsService,
@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
@@ -71,15 +90,14 @@ export class BookingInvoiceService {
/**
* React to a booking invoice being paid — the settlement branch point. Per-type
* reactions live here (not in the payment process): e.g. a paid up-front invoice
* may later generate a final invoice. Only PREPAID exists today.
* 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')
onBookingInvoicePaid(payload: InvoiceEventPayload): void {
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
switch (payload.type) {
case Freight.InvoiceType.Prepaid:
// Full prepaid settlement — booking advancement is handled by the
// payment flow today. Final-invoice issuance would hook in here.
await this.advanceBookingOnPayment(payload.sourceId);
break;
default:
this.logger.warn(
@@ -88,6 +106,70 @@ export class BookingInvoiceService {
}
}
/**
* Advance a booking once its prepaid invoice settles. This is the domain
* side-effect of payment, relocated out of the payment service: a general
* contract becomes ACTIVE and opens its ordering window (it does not enter the
* train queue — nothing has been ordered yet); a normal booking becomes PAID
* and is allocated into its batch. Idempotent — no-op when already PAID.
*/
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;
const paidAt = new Date();
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
let contractExpiresAt: Date | null = null;
if (isGeneralContract) {
const months = await this.contractPeriodMonths();
contractExpiresAt = new Date(paidAt);
contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months);
}
await this.dataSource.transaction(async (mg) => {
await mg.update(
Booking,
{ id: bookingId },
isGeneralContract
? { paymentStatus: 'PAID', status: 'CONTRACT_ACTIVE', expiresAt: contractExpiresAt }
: { paymentStatus: 'PAID', status: 'PAID' },
);
await this.firstMile.acceptBooking(bookingId);
});
if (isGeneralContract) {
this.logger.log(
`General contract ${booking.reference} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
);
return;
}
try {
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
} catch (err) {
this.logger.error(
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** Configured general-contract ordering window in months (defaults to 3). */
private async contractPeriodMonths(): Promise<number> {
try {
const setting = await this.dropdownSettings.getByCode(CONTRACT_PERIOD_SETTING_CODE);
const months = Number(setting.children?.[0]?.value);
if (Number.isFinite(months) && months > 0) return months;
} catch {
// Setting not seeded — fall back to the default.
}
return DEFAULT_CONTRACT_PERIOD_MONTHS;
}
/** Map a booking's pricing snapshot into a generic invoice request. */
private buildInput(booking: Booking): GenerateInvoiceInput | null {
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;

View File

@@ -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, "&quot;");
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>`;
}
}

View File

@@ -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 ?? '') : '',
};
}

View File

@@ -11,8 +11,11 @@ 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 { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.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 +38,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({
@@ -50,8 +52,9 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingReviewNote,
BookingContractSignature,
]),
PaymentModule,
BillingModule,
DropdownSettingsModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
FilesModule,
MinioModule,
@@ -66,7 +69,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [BookingsController, PayController],
controllers: [BookingsController, PayController, BookingPaymentController],
providers: [
BookingsService,
BookingsRepository,