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,13 +1,13 @@
import {
Body,
Controller,
Get,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
Res,
Body,
Post,
} from "@nestjs/common";
import {
ApiTags,
@@ -20,14 +20,7 @@ import { Response } from "express";
import { Public } from "@edr/api-common";
import { BookingView, FreightAdmin } from "../../common/booking-guards";
import { PaymentService } from "./payment.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
RefundDto,
} from "./payments.dto";
import { IntentStatusDto, RefundDto } from "./payments.dto";
@ApiTags("Payment")
@Controller("payments")
@@ -73,16 +66,6 @@ export class PaymentController {
});
}
@Post("initiate")
@ApiOperation({
summary: "Initiate payment for a freight booking",
description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`,
})
@ApiOkResponse({ type: InitiateResponseDto })
initiatePayment(@Body() dto: InitiatePaymentDto) {
return this.paymentService.initiatePayment(dto);
}
@Get("intents/:bookingId")
@ApiOperation({ summary: "Get payment intent status for a booking" })
@ApiOkResponse({ type: IntentStatusDto })
@@ -97,54 +80,6 @@ export class PaymentController {
return this.paymentService.refund(dto);
}
@Get("checkout")
@Public()
@ApiOperation({
summary: "Browser checkout redirect",
description:
"Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
})
@ApiQuery({ name: "bookingId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html")
async checkout(
@Query("bookingId") bookingId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!bookingId) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
}
try {
const result = await this.paymentService.initiatePayment({ bookingId, method, platform });
const url =
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
if (url) {
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
}
return res
.status(HttpStatus.OK)
.type("html")
.send(this.buildStatusHtml(result.status, result.intentId));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "An unexpected error occurred";
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
}
}
@Get("receipt/:orderId")
@Public()
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
@@ -153,76 +88,4 @@ export class PaymentController {
const html = await this.paymentService.genReceiptHtml(orderId);
return res.status(HttpStatus.OK).type("html").send(html);
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, """);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=${escaped}">
<title>Redirecting to payment…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
@keyframes spin { to { transform: rotate(360deg); } }
p { color: #555; margin: 0 0 16px; }
a { color: #1a73e8; }
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment status</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
small { color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="status">${status}</div>
<small>Intent: ${intentId}</small>
</div>
</body>
</html>`;
}
private buildErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment error</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
p { color: #555; }
</style>
</head>
<body>
<div class="card">
<div class="error">Payment could not be initiated</div>
<p>${message}</p>
</div>
</body>
</html>`;
}
}

View File

@@ -1,4 +1,4 @@
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
import { DynamicModule, forwardRef, Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
@@ -13,9 +13,6 @@ import {
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
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 { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentEntity } from "./entities/payment.entity";
@@ -59,10 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
BillingModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => BillingModule),
TypeOrmModule.forFeature([
PaymentEntity,
PaymentWebhookEventEntity,

View File

@@ -11,6 +11,7 @@ import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
import { PaymentClientService } from "./payment-client.service";
import { BillingService } from "../billing/billing.service";
import * as fs from "fs";
import * as path from "path";
@@ -22,26 +23,46 @@ import {
ProviderPaymentStatus,
} from "@edr/payment-providers";
import {
Freight,
PaymentService as PaymentServiceEnum,
PaymentReferenceType,
PaymentIntentSnapshot,
ProviderMethod,
} from "@edr/types";
import { BillingService } from "../billing/billing.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentPlatformDto,
RefundDto,
} 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. */
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
* the caller (billing) — this service never derives them from a domain record. */
export interface InitiateIntentInput {
/** Opaque domain reference (booking id, …). */
referenceId: string;
/** Human-readable order ref shown on provider pages. */
orderRef: string;
/** Authoritative amount in minor units, computed by the caller. */
amountMinor: number;
currency: string;
/** Stored on the intent projection for receipts/dashboards. */
reason?: string;
/** Provider/method selector. */
method: ProviderMethod | string;
platform?: PaymentPlatformDto;
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
export interface InitiateIntentResult {
intentId: string;
response: InitiateResponseDto;
/** True when the provider settled the charge synchronously during initiate. */
immediateSuccess: boolean;
providerTxnId?: string;
paidAt?: Date;
}
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
@@ -52,6 +73,23 @@ const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"refunded": ProviderPaymentStatus.CANCELLED,
};
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
TELEBIRR: "telebirr",
CBE_BIRR: "cbe-birr",
EBIRR: "ebirr",
WAAFI: "waafi",
CARD: "card",
DMONEY: "dmoney",
CAC_BANK: "cac-bank",
};
/**
* Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and
* NOTHING domain-specific: it never loads a booking, computes an amount, or
* advances a domain record. On settlement it notifies billing directly
* ({@link BillingService.settleByPaymentId}); billing (and through it, the domain)
* reacts. The billing↔payment pair is a deliberate forwardRef cycle.
*/
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
@@ -60,27 +98,10 @@ export class PaymentService {
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly dropdownSettings: DropdownSettingsService,
private readonly firstMileService: FirstMileService,
@Inject(forwardRef(() => 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: {
search?: string;
status?: string;
@@ -146,7 +167,6 @@ export class PaymentService {
total += row.count;
}
// Sum of successfully collected amounts.
const paidAgg = await this.paymentRepo
.createQueryBuilder("payment")
.select("COALESCE(SUM(payment.amount), 0)", "sum")
@@ -164,74 +184,75 @@ export class PaymentService {
};
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
const booking = await this.datasource
.getRepository(Booking)
.findOneBy({ id: dto.bookingId });
if (!booking) throw new NotFoundException("Booking not found");
// Charge the invoice (the billing document of record) so discounts,
// penalties and staff adjustments carried on it are honored. Fall back to
// 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),
);
/**
* Open a gateway intent for a caller-supplied amount/reference and project it
* locally. Returns the intent id (so billing can correlate the invoice) plus
* the client action. When the provider settles synchronously, the intent is
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
* has stored the intent id, avoiding a settle-before-correlation race.
*/
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: booking.id,
orderRef: booking.reference,
amountMinor,
currency: booking.paymentCurrency,
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl:'https://edrfreight.triaplc.com/payment/success',
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
referenceId: input.referenceId,
orderRef: input.orderRef,
amountMinor: input.amountMinor,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
});
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED;
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: booking.id,
const intent = await this.upsertIntent(input, snapshot);
if (immediateSuccess) {
// Settle the projection but DO NOT notify billing — billing settles
// inline once it has stored intentId on the invoice (see payInvoice),
// avoiding a settle-before-correlation race.
await this.markIntentSucceeded(intent.id, {
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
paidAt,
notify: false,
});
}
return this.formatIntentResponse(intent);
return {
intentId: intent.id,
// `intent` still reflects the projection status ("processing" on immediate
// success — settlement is applied by the caller, not shown synchronously).
response: this.formatIntentResponse(intent),
immediateSuccess,
providerTxnId: snapshot.providerTxnId,
paidAt,
};
}
private async syncIntentProjection(
bookingId: string,
booking: Booking,
/** Create or update the local intent projection from a provider snapshot. */
private async upsertIntent(
input: InitiateIntentInput,
snapshot: PaymentIntentSnapshot,
): Promise<PaymentEntity> {
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
const existing = await this.paymentRepo.findOneBy({
refId: input.referenceId,
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"] =
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
? "processing"
: this.toLocalStatus(snapshot.status);
const status =
snapshot.status === ProviderPaymentStatus.SUCCEEDED
? "processing"
: this.toLocalStatus(snapshot.status);
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
const clientAction = (snapshot.clientAction ?? undefined) as
| Record<string, unknown>
| undefined;
const data = {
status,
method,
@@ -248,30 +269,36 @@ export class PaymentService {
}
return this.paymentRepo.create({
refId: bookingId,
refId: input.referenceId,
type: "booking",
amount: booking.totalAmount,
currency: booking.paymentCurrency,
reason: `Payment for booking ${booking.reference}`,
amount: input.amountMinor,
currency: input.currency as PaymentEntity["currency"],
reason: input.reason ?? `Payment for ${input.orderRef}`,
rawInitiation: snapshot as unknown as Record<string, unknown>,
clientAction: clientAction ?? {},
...data,
} as any);
}
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
/**
* Reconcile an intent's status with the gateway by reference. Read-only on the
* domain side: it syncs the local projection and, when the provider reports a
* newly-observed success, notifies billing to settle. `referenceId` is opaque
* (the booking id, but this service does not load it).
*/
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ refId: referenceId, type: "booking" });
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.SHIPMENT,
bookingId,
referenceId,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
);
}
@@ -279,111 +306,54 @@ export class PaymentService {
if (!local) throw new NotFoundException("PaymentIntent not found");
return this.formatIntentStatus(local);
}
if (!local) throw new NotFoundException("PaymentIntent not found");
const booking = await this.datasource
.getRepository(Booking)
.findOneBy({ id: bookingId });
// Sync local projection with provider-reported status.
const becameSuccess =
snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success";
if (!booking) throw new NotFoundException("Booking not found");
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: booking.id,
if (becameSuccess) {
await this.markIntentSucceeded(local.id, {
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
notify: true,
});
} else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) {
await this.paymentRepo.update(
{ id: local.id },
{
status: this.toLocalStatus(snapshot.status),
failerCode: snapshot.failureCode ?? undefined,
failureMessage: snapshot.failureMessage ?? undefined,
},
);
}
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
return this.formatIntentStatus(refreshed ?? intent);
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
return this.formatIntentStatus(refreshed ?? local);
}
async refund(dto: RefundDto) {
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
if (!intent || intent.status !== "success") {
throw new BadRequestException("No successful payment to refund");
}
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 finalizePaymentSuccess(input: {
intentId: string;
bookingId: string;
providerTxnId?: string;
paidAt?: Date;
}): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
/**
* Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
* when the caller settles inline and will trigger settlement itself.
*/
async markIntentSucceeded(
intentId: string,
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true };
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
// queue (nothing has been ordered yet). Instead it becomes ACTIVE and
// 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)}`,
);
if (opts.notify !== false) {
await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt);
}
return { alreadyFinalized: false };
@@ -402,6 +372,29 @@ export class PaymentService {
{ id: intent.id },
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
);
// Invoice stays open for retry — nothing to settle. Logged only.
this.logger.warn(
`Payment ${intent.id} failed for ${intent.refId}` +
(input.failureMessage ? `: ${input.failureMessage}` : ""),
);
}
async refund(dto: RefundDto) {
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
if (!intent || intent.status !== "success") {
throw new BadRequestException("No successful payment to refund");
}
// NOTE: refunding still mutates the booking directly — left intact pending
// the refund redesign. TODO: route refunds through billing.refundPayable +
// a `${source}.invoice.refunded` reaction, like settlement.
await this.datasource.transaction(async (mg) => {
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
});
return { refunded: true, bookingId: dto.bookingId };
}
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
@@ -468,13 +461,12 @@ export class PaymentService {
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
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({
intentId: intent.id,
bookingId: event.referenceId,
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
notify: true,
});
return { processed: true, alreadyFinalized };
}
@@ -482,7 +474,7 @@ export class PaymentService {
if (event.eventType === "payment.failed") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
}
await this.markPaymentFailed({
intentId: intent.id,