mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 20:38:17 +00:00
feat: rewired up the billing and payment with the booking
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
|
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { BillingController } from "./billing.controller";
|
import { BillingController } from "./billing.controller";
|
||||||
@@ -7,9 +7,13 @@ import { Invoice } from "./entities/invoice.entity";
|
|||||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
import { InvoiceRepository } from "./invoice.repository";
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
|
import { PaymentModule } from "../payment/payment.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Invoice, InvoiceLine])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||||
|
forwardRef(() => PaymentModule),
|
||||||
|
],
|
||||||
controllers: [BillingController],
|
controllers: [BillingController],
|
||||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||||
exports: [BillingService],
|
exports: [BillingService],
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
events as never,
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
events as never,
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -165,6 +167,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
events as never,
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -192,6 +195,7 @@ describe("BillingService.settlePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
events as never,
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
);
|
);
|
||||||
|
|
||||||
const settled = await service.settlePayable(
|
const settled = await service.settlePayable(
|
||||||
@@ -224,6 +228,7 @@ describe("BillingService.settlePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
events as never,
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
);
|
);
|
||||||
|
|
||||||
const settled = await service.settlePayable(
|
const settled = await service.settlePayable(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
@@ -7,6 +7,8 @@ import { Invoice } from "./entities/invoice.entity";
|
|||||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
import { InvoiceRepository } from "./invoice.repository";
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
|
import { PaymentService } from "../payment/payment.service";
|
||||||
|
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||||
|
|
||||||
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||||
const DEFAULT_DUE_DAYS = 14;
|
const DEFAULT_DUE_DAYS = 14;
|
||||||
@@ -80,6 +82,8 @@ export class BillingService {
|
|||||||
private readonly invoices: InvoiceRepository,
|
private readonly invoices: InvoiceRepository,
|
||||||
private readonly invoiceLines: InvoiceLineRepository,
|
private readonly invoiceLines: InvoiceLineRepository,
|
||||||
private readonly events: EventEmitter2,
|
private readonly events: EventEmitter2,
|
||||||
|
@Inject(forwardRef(() => PaymentService))
|
||||||
|
private readonly payment: PaymentService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -304,9 +308,9 @@ export class BillingService {
|
|||||||
/**
|
/**
|
||||||
* The invoice a gateway payment should settle for a source record, or null if
|
* 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
|
* none. This is the billing document of record for "what is owed" — callers
|
||||||
* (e.g. `payment.service.initiatePayment`) should charge `invoice.totalAmount`
|
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
|
||||||
* against it rather than recomputing from the source's own total, so
|
* recomputing from the source's own total, so discounts/penalties/adjustments
|
||||||
* discounts/penalties/adjustments carried on the invoice are honored.
|
* carried on the invoice are honored.
|
||||||
*
|
*
|
||||||
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
* 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
|
* a booking's up-front vs final charge); omit it to settle whichever single
|
||||||
@@ -335,10 +339,13 @@ export class BillingService {
|
|||||||
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
|
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
|
||||||
* settlement. No-op (returns null) when the source has no open invoice.
|
* settlement. No-op (returns null) when the source has no open invoice.
|
||||||
*
|
*
|
||||||
* Type-blind by design: the payment process settles whichever invoice is due;
|
* Type-blind by design: settles whichever invoice is due; any per-type reaction
|
||||||
* any per-type reaction belongs in the `${source}.invoice.paid` handler, which
|
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
|
||||||
* reads `invoice.type`. Pass the caller's transaction `manager` (e.g. from
|
* Pass the caller's transaction `manager` to enlist in its DB transaction.
|
||||||
* `payment.service.finalizePaymentSuccess`) 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(
|
async settlePayable(
|
||||||
source: Freight.InvoiceSource,
|
source: Freight.InvoiceSource,
|
||||||
@@ -378,4 +385,83 @@ export class BillingService {
|
|||||||
|
|
||||||
return this.markInvoiceAsRefunded(invoice.id, mg);
|
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,
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { Freight } from '@edr/types';
|
import { Freight } from '@edr/types';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BillingService,
|
BillingService,
|
||||||
@@ -9,7 +10,11 @@ import {
|
|||||||
InvoiceLineInput,
|
InvoiceLineInput,
|
||||||
} from '../billing/billing.service';
|
} from '../billing/billing.service';
|
||||||
import { Invoice } from '../billing/entities/invoice.entity';
|
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 { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||||
@@ -22,18 +27,32 @@ interface StoredPricingBreakdown {
|
|||||||
/** Round to 2 decimals, avoiding binary float drift. */
|
/** Round to 2 decimals, avoiding binary float drift. */
|
||||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
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
|
* 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
|
* turns into invoices, which type to use, and how it advances when paid. Bookings
|
||||||
* entity, so they generate their own invoices directly via {@link BillingService}
|
* are the billable business entity, so they generate their own invoices directly
|
||||||
* (billing stays source-agnostic). All booking-specific type branching lives here,
|
* via {@link BillingService} (billing stays source-agnostic). All booking-specific
|
||||||
* at the two points it belongs: invoice creation and settlement (the paid handler).
|
* type branching lives here, at the two points it belongs: invoice creation and
|
||||||
|
* settlement (the paid handler).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingInvoiceService {
|
export class BookingInvoiceService {
|
||||||
private readonly logger = new Logger(BookingInvoiceService.name);
|
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
|
* 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
|
* 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
|
* reactions live here (not in the payment process): each invoice type advances
|
||||||
* may later generate a final invoice. Only PREPAID exists today.
|
* the booking its own way. Only PREPAID exists today.
|
||||||
*/
|
*/
|
||||||
@OnEvent('booking.invoice.paid')
|
@OnEvent('booking.invoice.paid')
|
||||||
onBookingInvoicePaid(payload: InvoiceEventPayload): void {
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
switch (payload.type) {
|
switch (payload.type) {
|
||||||
case Freight.InvoiceType.Prepaid:
|
case Freight.InvoiceType.Prepaid:
|
||||||
// Full prepaid settlement — booking advancement is handled by the
|
await this.advanceBookingOnPayment(payload.sourceId);
|
||||||
// payment flow today. Final-invoice issuance would hook in here.
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
this.logger.warn(
|
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. */
|
/** Map a booking's pricing snapshot into a generic invoice request. */
|
||||||
private buildInput(booking: Booking): GenerateInvoiceInput | null {
|
private buildInput(booking: Booking): GenerateInvoiceInput | null {
|
||||||
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
|
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
|
||||||
|
|||||||
@@ -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 { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||||
import { PaymentService } from '../payment/payment.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { PaymentStatus } from '../payment/entities/payment.entity';
|
|
||||||
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
||||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||||
|
|
||||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
|
||||||
"action-required",
|
|
||||||
"processing",
|
|
||||||
"success",
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingPaymentService {
|
export class BookingPaymentService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
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 }> {
|
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||||
|
|
||||||
const existing = await this.paymentService.findBookingById(bookingId);
|
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, 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,
|
|
||||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||||
platform: "web",
|
platform: 'web',
|
||||||
});
|
});
|
||||||
|
|
||||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||||
return {
|
return {
|
||||||
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
|
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
|||||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { BillingModule } from '../billing/billing.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 { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingInvoiceService } from './booking-invoice.service';
|
import { BookingInvoiceService } from './booking-invoice.service';
|
||||||
|
import { BookingPaymentController } from './booking-payment.controller';
|
||||||
import { BookingPaymentService } from './booking-payment.service';
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingReferenceDataService } from './booking-reference-data.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 { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||||
import { PaymentModule } from '../payment/payment.module';
|
|
||||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -50,8 +52,9 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
]),
|
]),
|
||||||
PaymentModule,
|
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
DropdownSettingsModule,
|
||||||
|
forwardRef(() => FirstMileModule),
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
FilesModule,
|
FilesModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
@@ -66,7 +69,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [BookingsController, PayController],
|
controllers: [BookingsController, PayController, BookingPaymentController],
|
||||||
providers: [
|
providers: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Post,
|
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
|
Body,
|
||||||
|
Post,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@@ -20,14 +20,7 @@ import { Response } from "express";
|
|||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import {
|
import { IntentStatusDto, RefundDto } from "./payments.dto";
|
||||||
InitiatePaymentDto,
|
|
||||||
InitiateResponseDto,
|
|
||||||
IntentStatusDto,
|
|
||||||
PaymentMethodTypeEnum,
|
|
||||||
PaymentPlatformDto,
|
|
||||||
RefundDto,
|
|
||||||
} from "./payments.dto";
|
|
||||||
|
|
||||||
@ApiTags("Payment")
|
@ApiTags("Payment")
|
||||||
@Controller("payments")
|
@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")
|
@Get("intents/:bookingId")
|
||||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||||
@ApiOkResponse({ type: IntentStatusDto })
|
@ApiOkResponse({ type: IntentStatusDto })
|
||||||
@@ -97,54 +80,6 @@ export class PaymentController {
|
|||||||
return this.paymentService.refund(dto);
|
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")
|
@Get("receipt/:orderId")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||||
@@ -153,76 +88,4 @@ export class PaymentController {
|
|||||||
const html = await this.paymentService.genReceiptHtml(orderId);
|
const html = await this.paymentService.genReceiptHtml(orderId);
|
||||||
return res.status(HttpStatus.OK).type("html").send(html);
|
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 { HttpModule } from "@nestjs/axios";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||||
@@ -13,9 +13,6 @@ import {
|
|||||||
|
|
||||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
import { BillingModule } from "../billing/billing.module";
|
import { BillingModule } from "../billing/billing.module";
|
||||||
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
|
|
||||||
import { FirstMileModule } from "../first-mile/first-mile.module";
|
|
||||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
|
||||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||||
import { PaymentEntity } from "./entities/payment.entity";
|
import { PaymentEntity } from "./entities/payment.entity";
|
||||||
@@ -59,10 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
|
|||||||
imports: [
|
imports: [
|
||||||
HttpModule.register({ timeout: 10_000 }),
|
HttpModule.register({ timeout: 10_000 }),
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
DropdownSettingsModule,
|
forwardRef(() => BillingModule),
|
||||||
BillingModule,
|
|
||||||
forwardRef(() => FirstMileModule),
|
|
||||||
forwardRef(() => TrainSchedulingModule),
|
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
PaymentEntity,
|
PaymentEntity,
|
||||||
PaymentWebhookEventEntity,
|
PaymentWebhookEventEntity,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { DataSource } from "typeorm";
|
|||||||
import { PaymentEntity } from "./entities/payment.entity";
|
import { PaymentEntity } from "./entities/payment.entity";
|
||||||
import { PaymentRepository } from "./payment.repository";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
import { PaymentClientService } from "./payment-client.service";
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
@@ -22,26 +23,46 @@ import {
|
|||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
} from "@edr/payment-providers";
|
} from "@edr/payment-providers";
|
||||||
import {
|
import {
|
||||||
Freight,
|
|
||||||
PaymentService as PaymentServiceEnum,
|
PaymentService as PaymentServiceEnum,
|
||||||
PaymentReferenceType,
|
PaymentReferenceType,
|
||||||
PaymentIntentSnapshot,
|
PaymentIntentSnapshot,
|
||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
import { BillingService } from "../billing/billing.service";
|
|
||||||
import {
|
import {
|
||||||
InitiatePaymentDto,
|
|
||||||
InitiateResponseDto,
|
InitiateResponseDto,
|
||||||
IntentStatusDto,
|
IntentStatusDto,
|
||||||
|
PaymentPlatformDto,
|
||||||
RefundDto,
|
RefundDto,
|
||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
|
||||||
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
|
|
||||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
|
||||||
|
|
||||||
/** Setting code holding the global ordering window (months) for general contracts. */
|
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
||||||
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
|
* the caller (billing) — this service never derives them from a domain record. */
|
||||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
export interface InitiateIntentInput {
|
||||||
|
/** Opaque domain reference (booking id, …). */
|
||||||
|
referenceId: string;
|
||||||
|
/** 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> = {
|
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
@@ -52,6 +73,23 @@ const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
|||||||
"refunded": ProviderPaymentStatus.CANCELLED,
|
"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()
|
@Injectable()
|
||||||
export class PaymentService {
|
export class PaymentService {
|
||||||
private readonly logger = new Logger(PaymentService.name);
|
private readonly logger = new Logger(PaymentService.name);
|
||||||
@@ -60,27 +98,10 @@ export class PaymentService {
|
|||||||
private readonly datasource: DataSource,
|
private readonly datasource: DataSource,
|
||||||
private readonly paymentRepo: PaymentRepository,
|
private readonly paymentRepo: PaymentRepository,
|
||||||
private readonly paymentClient: PaymentClientService,
|
private readonly paymentClient: PaymentClientService,
|
||||||
@Inject(forwardRef(() => BookingBatchService))
|
@Inject(forwardRef(() => BillingService))
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
|
||||||
private readonly dropdownSettings: DropdownSettingsService,
|
|
||||||
private readonly firstMileService: FirstMileService,
|
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAll(filters: {
|
async getAll(filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -146,7 +167,6 @@ export class PaymentService {
|
|||||||
total += row.count;
|
total += row.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sum of successfully collected amounts.
|
|
||||||
const paidAgg = await this.paymentRepo
|
const paidAgg = await this.paymentRepo
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
||||||
@@ -164,74 +184,75 @@ export class PaymentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
/**
|
||||||
const booking = await this.datasource
|
* Open a gateway intent for a caller-supplied amount/reference and project it
|
||||||
.getRepository(Booking)
|
* locally. Returns the intent id (so billing can correlate the invoice) plus
|
||||||
.findOneBy({ id: dto.bookingId });
|
* the client action. When the provider settles synchronously, the intent is
|
||||||
if (!booking) throw new NotFoundException("Booking not found");
|
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
|
||||||
|
* has stored the intent id, avoiding a settle-before-correlation race.
|
||||||
// Charge the invoice (the billing document of record) so discounts,
|
*/
|
||||||
// penalties and staff adjustments carried on it are honored. Fall back to
|
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||||
// the booking total only when no invoice has been generated yet.
|
|
||||||
const invoice = await this.billing.findPayable(
|
|
||||||
Freight.InvoiceSource.Booking,
|
|
||||||
booking.id,
|
|
||||||
);
|
|
||||||
const amountMinor = Math.round(
|
|
||||||
Number(invoice?.totalAmount ?? booking.totalAmount),
|
|
||||||
);
|
|
||||||
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.FREIGHT,
|
service: PaymentServiceEnum.FREIGHT,
|
||||||
referenceType: PaymentReferenceType.SHIPMENT,
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
referenceId: booking.id,
|
referenceId: input.referenceId,
|
||||||
orderRef: booking.reference,
|
orderRef: input.orderRef,
|
||||||
amountMinor,
|
amountMinor: input.amountMinor,
|
||||||
currency: booking.paymentCurrency,
|
currency: input.currency,
|
||||||
provider: dto.method as unknown as ProviderMethod,
|
provider: input.method as ProviderMethod,
|
||||||
platform: dto.platform,
|
platform: input.platform,
|
||||||
payerAccount: dto.payerAccount,
|
payerAccount: input.payerAccount,
|
||||||
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||||
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
const intent = await this.upsertIntent(input, snapshot);
|
||||||
await this.finalizePaymentSuccess({
|
|
||||||
intentId: intent.id,
|
if (immediateSuccess) {
|
||||||
bookingId: booking.id,
|
// 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,
|
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(
|
/** Create or update the local intent projection from a provider snapshot. */
|
||||||
bookingId: string,
|
private async upsertIntent(
|
||||||
booking: Booking,
|
input: InitiateIntentInput,
|
||||||
snapshot: PaymentIntentSnapshot,
|
snapshot: PaymentIntentSnapshot,
|
||||||
): Promise<PaymentEntity> {
|
): Promise<PaymentEntity> {
|
||||||
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
const existing = await this.paymentRepo.findOneBy({
|
||||||
|
refId: input.referenceId,
|
||||||
|
type: "booking",
|
||||||
|
});
|
||||||
|
|
||||||
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"] =
|
const method: PaymentEntity["method"] =
|
||||||
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
||||||
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
const status =
|
||||||
? "processing"
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||||
: this.toLocalStatus(snapshot.status);
|
? "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 = {
|
const data = {
|
||||||
status,
|
status,
|
||||||
method,
|
method,
|
||||||
@@ -248,30 +269,36 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.paymentRepo.create({
|
return this.paymentRepo.create({
|
||||||
refId: bookingId,
|
refId: input.referenceId,
|
||||||
type: "booking",
|
type: "booking",
|
||||||
amount: booking.totalAmount,
|
amount: input.amountMinor,
|
||||||
currency: booking.paymentCurrency,
|
currency: input.currency as PaymentEntity["currency"],
|
||||||
reason: `Payment for booking ${booking.reference}`,
|
reason: input.reason ?? `Payment for ${input.orderRef}`,
|
||||||
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
||||||
clientAction: clientAction ?? {},
|
clientAction: clientAction ?? {},
|
||||||
...data,
|
...data,
|
||||||
} as any);
|
} 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, type: "booking" });
|
||||||
|
|
||||||
let snapshot: PaymentIntentSnapshot | null = null;
|
let snapshot: PaymentIntentSnapshot | null = null;
|
||||||
try {
|
try {
|
||||||
snapshot = await this.paymentClient.getIntentByReference(
|
snapshot = await this.paymentClient.getIntentByReference(
|
||||||
PaymentReferenceType.SHIPMENT,
|
PaymentReferenceType.SHIPMENT,
|
||||||
bookingId,
|
referenceId,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
this.logger.warn(
|
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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,111 +306,54 @@ export class PaymentService {
|
|||||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||||
return this.formatIntentStatus(local);
|
return this.formatIntentStatus(local);
|
||||||
}
|
}
|
||||||
|
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||||
|
|
||||||
const booking = await this.datasource
|
// Sync local projection with provider-reported status.
|
||||||
.getRepository(Booking)
|
const becameSuccess =
|
||||||
.findOneBy({ id: bookingId });
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success";
|
||||||
|
|
||||||
if (!booking) throw new NotFoundException("Booking not found");
|
if (becameSuccess) {
|
||||||
|
await this.markIntentSucceeded(local.id, {
|
||||||
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
|
|
||||||
|
|
||||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
|
||||||
await this.finalizePaymentSuccess({
|
|
||||||
intentId: intent.id,
|
|
||||||
bookingId: booking.id,
|
|
||||||
providerTxnId: snapshot.providerTxnId,
|
providerTxnId: snapshot.providerTxnId,
|
||||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
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 });
|
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
||||||
return this.formatIntentStatus(refreshed ?? intent);
|
return this.formatIntentStatus(refreshed ?? local);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refund(dto: RefundDto) {
|
/**
|
||||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||||
if (!intent || intent.status !== "success") {
|
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
||||||
throw new BadRequestException("No successful payment to refund");
|
* when the caller settles inline and will trigger settlement itself.
|
||||||
}
|
*/
|
||||||
|
async markIntentSucceeded(
|
||||||
await this.datasource.transaction(async (mg) => {
|
intentId: string,
|
||||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
|
||||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
): Promise<{ alreadyFinalized: boolean }> {
|
||||||
});
|
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||||
|
|
||||||
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) throw new NotFoundException("PaymentIntent not found");
|
||||||
if (intent.status === "success") return { alreadyFinalized: true };
|
if (intent.status === "success") return { alreadyFinalized: true };
|
||||||
|
|
||||||
const paidAt = input.paidAt ?? new Date();
|
const paidAt = opts.paidAt ?? new Date();
|
||||||
|
await this.paymentRepo.update(
|
||||||
|
{ id: intent.id },
|
||||||
|
{ status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId },
|
||||||
|
);
|
||||||
|
|
||||||
// A general contract is paid once, up front; it does NOT enter the train
|
if (opts.notify !== false) {
|
||||||
// queue (nothing has been ordered yet). Instead it becomes ACTIVE and
|
await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt);
|
||||||
// opens its ordering window. Orders placed later spawn their own paid
|
|
||||||
// child bookings that go through the normal pipeline.
|
|
||||||
const booking = await this.datasource
|
|
||||||
.getRepository(Booking)
|
|
||||||
.findOne({ where: { id: input.bookingId } });
|
|
||||||
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(
|
|
||||||
PaymentEntity,
|
|
||||||
{ id: intent.id },
|
|
||||||
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
|
||||||
);
|
|
||||||
await mg.update(
|
|
||||||
Booking,
|
|
||||||
{ id: input.bookingId },
|
|
||||||
isGeneralContract
|
|
||||||
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
|
|
||||||
: { paymentStatus: "PAID", status: "PAID" },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Settle the booking's open invoice in the same transaction and link
|
|
||||||
// this payment. The invoice emits `booking.invoice.paid` for the source
|
|
||||||
// to react to. No-op if the booking has no open invoice.
|
|
||||||
await this.billing.settlePayable(
|
|
||||||
Freight.InvoiceSource.Booking,
|
|
||||||
input.bookingId,
|
|
||||||
intent.id,
|
|
||||||
mg,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.firstMileService.acceptBooking(input.bookingId);
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isGeneralContract) {
|
|
||||||
this.logger.log(
|
|
||||||
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
|
|
||||||
);
|
|
||||||
return { alreadyFinalized: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { alreadyFinalized: false };
|
return { alreadyFinalized: false };
|
||||||
@@ -402,6 +372,29 @@ export class PaymentService {
|
|||||||
{ id: intent.id },
|
{ id: intent.id },
|
||||||
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
|
{ 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> {
|
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||||
@@ -468,13 +461,12 @@ export class PaymentService {
|
|||||||
if (event.eventType === "payment.succeeded") {
|
if (event.eventType === "payment.succeeded") {
|
||||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||||
if (!intent) {
|
if (!intent) {
|
||||||
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||||
}
|
}
|
||||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
||||||
intentId: intent.id,
|
|
||||||
bookingId: event.referenceId,
|
|
||||||
providerTxnId: event.providerTxnId,
|
providerTxnId: event.providerTxnId,
|
||||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||||
|
notify: true,
|
||||||
});
|
});
|
||||||
return { processed: true, alreadyFinalized };
|
return { processed: true, alreadyFinalized };
|
||||||
}
|
}
|
||||||
@@ -482,7 +474,7 @@ export class PaymentService {
|
|||||||
if (event.eventType === "payment.failed") {
|
if (event.eventType === "payment.failed") {
|
||||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||||
if (!intent) {
|
if (!intent) {
|
||||||
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||||
}
|
}
|
||||||
await this.markPaymentFailed({
|
await this.markPaymentFailed({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
|
|||||||
@@ -140,11 +140,18 @@ export enum InvoiceStatus {
|
|||||||
/** Originating subsystem an invoice bills for; namespaces invoice events. */
|
/** Originating subsystem an invoice bills for; namespaces invoice events. */
|
||||||
export enum InvoiceSource {
|
export enum InvoiceSource {
|
||||||
Booking = "booking",
|
Booking = "booking",
|
||||||
Contract = "contract",
|
|
||||||
Warehouse = "warehouse",
|
Warehouse = "warehouse",
|
||||||
Demurrage = "demurrage",
|
Demurrage = "demurrage",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an invoice bills for within its source — the discriminator when one
|
||||||
|
* entity carries several invoices (e.g. a booking's up-front vs final charge).
|
||||||
|
*/
|
||||||
|
export enum InvoiceType {
|
||||||
|
Prepaid = "PREPAID",
|
||||||
|
}
|
||||||
|
|
||||||
export enum SchedulingStatus {
|
export enum SchedulingStatus {
|
||||||
NotScheduled = "NOT_SCHEDULED",
|
NotScheduled = "NOT_SCHEDULED",
|
||||||
Holding = "HOLDING",
|
Holding = "HOLDING",
|
||||||
|
|||||||
Reference in New Issue
Block a user