mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
@@ -10,6 +10,8 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
@@ -109,6 +111,7 @@ export class BookingsService {
|
||||
private readonly currencyService: CurrencyService,
|
||||
private readonly fareEngine: FareEngineService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
) {}
|
||||
|
||||
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
|
||||
@@ -2129,6 +2132,14 @@ export class BookingsService {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
|
||||
// Verify-before-cancel: a still-PENDING_PAYMENT booking may actually be paid (its confirm event
|
||||
// was lost/late). reconcileAndConfirmIfPaid confirms it synchronously if so — refuse to cancel a
|
||||
// paid, or currently-unverifiable, booking as "unpaid".
|
||||
if (booking.status === 'PENDING_PAYMENT') {
|
||||
const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(booking.id);
|
||||
if (paid) throw new BadRequestException('Payment for this booking has completed; it is now confirmed and cannot be cancelled as unpaid.');
|
||||
if (!verified) throw new BadRequestException('Could not verify payment status right now; please try again shortly.');
|
||||
}
|
||||
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
|
||||
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
@@ -2265,11 +2276,24 @@ export class BookingsService {
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expirePendingBookings() {
|
||||
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
|
||||
// NEUTRALIZED (was 20 minutes): the payment window is MAX_PAYMENT_HOURS (2h). Bookings must
|
||||
// NEVER be cancelled at 20 minutes — the payer still has up to 2 hours, and the seat hold is
|
||||
// held for exactly this window. Aligned to the 2-hour window so this cron can only ever act as
|
||||
// a safe backup to the primary deadline-aware sweep (TasksService.cancelExpiredPendingBookings);
|
||||
// it never cancels prematurely, and paid bookings are still protected by the guard below.
|
||||
const cutoff = new Date(Date.now() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
|
||||
for (const b of expired) {
|
||||
await this.seatsService.releaseSeats(b.id);
|
||||
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
|
||||
try {
|
||||
// Never cancel a paid booking whose confirm event was lost/late — verify first (this
|
||||
// confirms it synchronously if paid). Skip when paid or currently unverifiable.
|
||||
const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(b.id);
|
||||
if (paid || !verified) continue;
|
||||
await this.seatsService.releaseSeats(b.id);
|
||||
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
|
||||
} catch (err) {
|
||||
this.logger.error(`expirePendingBookings failed for ${b.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,17 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryRequestDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payments.dto";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
/**
|
||||
@@ -18,6 +24,9 @@ import { PaymentsService } from "./payments.service";
|
||||
* consumer when RabbitMQ lands — the handler logic is transport-agnostic.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
// isPublic only skips the global IAM user-JWT guard — these routes stay protected by
|
||||
// ServiceAuthGuard's shared service token (the payment service is not an IAM user).
|
||||
@SetMetadata("isPublic", true)
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentsController {
|
||||
@@ -32,4 +41,16 @@ export class InternalPaymentsController {
|
||||
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||
return this.paymentsService.handlePaymentEvent(event);
|
||||
}
|
||||
|
||||
@Post("bill-query")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
|
||||
})
|
||||
async billQuery(
|
||||
@Body() request: BillQueryRequestDto,
|
||||
): Promise<BillQueryResponseDto> {
|
||||
return this.paymentsService.billQuery(request.referenceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,3 +55,24 @@ export class MarkPaidResponseDto {
|
||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||
@ApiPropertyOptional() reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
|
||||
* "is this order still payable, by whom, for how much" while a CBE teller/app is on the line.
|
||||
*/
|
||||
export class BillQueryRequestDto {
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: PaymentReferenceType;
|
||||
|
||||
@ApiProperty() @IsString() referenceId!: string;
|
||||
}
|
||||
|
||||
export class BillQueryResponseDto {
|
||||
@ApiProperty() stillPayable!: boolean;
|
||||
@ApiPropertyOptional() payerName?: string | null;
|
||||
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
||||
@ApiPropertyOptional() currency?: string | null;
|
||||
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
|
||||
@ApiPropertyOptional() reason?: string | null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,17 @@ export interface PaymentDiagnostic {
|
||||
provider: ProviderStatus | null;
|
||||
}
|
||||
|
||||
/** Settlement check from POST /payments/reconcile (verify-before-cancel). */
|
||||
export interface SettlementResult {
|
||||
/** At least one intent for the order is paid (incl. a late capture just registered). */
|
||||
paid: boolean;
|
||||
/** The paying intent when `paid`. */
|
||||
intent?: PaymentIntentSnapshot;
|
||||
/** Settlement could not be confirmed — a provider query errored, a payment is in flight, OR the
|
||||
* payment service was unreachable. The caller MUST NOT cancel the order. */
|
||||
unverifiable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's
|
||||
* side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here;
|
||||
@@ -85,6 +96,31 @@ export class PaymentClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/reconcile — settlement check before cancelling an order. Live-queries every
|
||||
* intent at the provider and registers any late capture found. A transport failure (payment
|
||||
* service unreachable) is caught and returned as `unverifiable: true` — NEVER as "not paid" — so
|
||||
* the caller does not cancel a booking whose payment simply could not be verified.
|
||||
*/
|
||||
async reconcileByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<SettlementResult> {
|
||||
try {
|
||||
return await this.call<SettlementResult>("POST", "/payments/reconcile", {
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`reconcile ${referenceType}/${referenceId} failed: ${message}; treating as unverifiable (will not cancel)`,
|
||||
);
|
||||
return { paid: false, unverifiable: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
|
||||
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a
|
||||
|
||||
@@ -2,9 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { PaymentClientService } from './payment-client.service';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
|
||||
|
||||
// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up).
|
||||
// This service keeps only singleton deps so its @Cron method registers correctly,
|
||||
@@ -16,12 +14,11 @@ export class PaymentSyncService {
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// Every 30 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// whose payment intent has moved to SUCCEEDED on the gateway but whose
|
||||
// confirmation event was never delivered (missed RabbitMQ message, network
|
||||
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
|
||||
@@ -30,7 +27,7 @@ export class PaymentSyncService {
|
||||
// Processes at most 50 bookings per cycle to avoid hammering the payment
|
||||
// service; the next tick picks up the remainder.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
@Cron('*/30 * * * *')
|
||||
async syncPaymentStatuses() {
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
@@ -47,7 +44,6 @@ export class PaymentSyncService {
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
let confirmed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
|
||||
// resolve() (not get()) because PaymentsService is scoped — same pattern
|
||||
@@ -59,37 +55,17 @@ export class PaymentSyncService {
|
||||
);
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!booking.paymentIntent) continue;
|
||||
|
||||
try {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
|
||||
if (!snapshot) continue;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const result = await paymentsService.finalizePaymentSuccess({
|
||||
intentId: booking.paymentIntent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
if (!result.alreadyFinalized) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
} else if (
|
||||
snapshot.status === ProviderPaymentStatus.FAILED ||
|
||||
snapshot.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` +
|
||||
`booking will be auto-cancelled at payment deadline`,
|
||||
);
|
||||
failed++;
|
||||
// Reconcile ALL intents at the provider — including terminal (cancelled/expired) ones —
|
||||
// and confirm synchronously if any is paid. Unlike getIntentByReference this catches BOTH
|
||||
// a lost confirm event (payment-api already SUCCEEDED) AND a payment recorded only at the
|
||||
// provider (local intent terminal). Idempotent, so a re-run is safe.
|
||||
const { paid } = await paymentsService.reconcileAndConfirmIfPaid(booking.id);
|
||||
if (paid) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
// not paid / unverifiable → still pending; retried next cycle (or cancelled at deadline)
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
@@ -99,10 +75,9 @@ export class PaymentSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed > 0 || failed > 0 || errored > 0) {
|
||||
if (confirmed > 0 || errored > 0) {
|
||||
this.logger.log(
|
||||
`Payment sync run: ${bookings.length} checked, ` +
|
||||
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
|
||||
`Payment sync run: ${bookings.length} checked, ${confirmed} confirmed, ${errored} errors`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum PaymentMethodTypeEnum {
|
||||
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
|
||||
CARD = "CARD", // International
|
||||
WALLET = "WALLET", // Internal
|
||||
CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number)
|
||||
}
|
||||
|
||||
export type PaymentPlatformDto = "web" | "mobile";
|
||||
@@ -115,8 +116,10 @@ export class SupportedPaymentMethodDto {
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
})
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@ApiPropertyOptional({
|
||||
@@ -135,6 +138,14 @@ export class ClientActionDto {
|
||||
providerOrderId?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||
message?: string;
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
|
||||
})
|
||||
billReference?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
instructions?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PaymentsService } from "./payments.service";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { AuditService } from "../../common/audit.service";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
import { TicketsService } from "../tickets/tickets.service";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
@@ -27,6 +28,7 @@ describe("PaymentsService", () => {
|
||||
booking: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
paymentIntent: {
|
||||
findUnique: jest.fn(),
|
||||
@@ -81,6 +83,8 @@ describe("PaymentsService", () => {
|
||||
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
|
||||
Promise.resolve(minor),
|
||||
),
|
||||
displayMinorToChargeMajor: jest.fn((minor: number) => minor / 100),
|
||||
convertMinorToChargeMajor: jest.fn(async (minor: number) => minor / 100),
|
||||
getRateOrThrow: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -109,6 +113,7 @@ describe("PaymentsService", () => {
|
||||
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
||||
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
||||
{ provide: CurrencyService, useValue: mockCurrencyService },
|
||||
{ provide: AuditService, useValue: { log: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
PaymentRegionEnum,
|
||||
ForceConfirmDto,
|
||||
} from "./payments.dto";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payments.dto";
|
||||
import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils";
|
||||
import {
|
||||
PaymentClientService,
|
||||
PaymentDiagnostic,
|
||||
@@ -222,6 +227,17 @@ export class PaymentsService {
|
||||
);
|
||||
}
|
||||
|
||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT
|
||||
// required — CBE identifies the payer at its own channel.
|
||||
if (
|
||||
method === PaymentMethodType.CBE_BILL &&
|
||||
(booking.currency ?? "ETB").toUpperCase() !== "ETB"
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"CBE bill payment is only available for bookings charged in ETB",
|
||||
);
|
||||
}
|
||||
|
||||
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
||||
|
||||
// Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
|
||||
@@ -244,74 +260,9 @@ export class PaymentsService {
|
||||
return this.initiateWalletPayment(booking);
|
||||
}
|
||||
|
||||
// Double-charge guard for payment-method switches. Before opening a fresh charge over
|
||||
// this booking, reconcile any still-open intent against the authoritative provider
|
||||
// status — the booking-status check above only blocks once the booking is CONFIRMED,
|
||||
// which leaves a window where the first attempt actually paid but the mark-paid
|
||||
// webhook/poll hasn't landed yet.
|
||||
const existingIntent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
if (existingIntent && NON_TERMINAL_STATUSES.includes(existingIntent.status)) {
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment reconcile before initiate failed for booking ${booking.id}: ${message}; treating existing intent as still open`,
|
||||
);
|
||||
}
|
||||
|
||||
// The previous attempt actually paid (provider SUCCEEDED, event just late):
|
||||
// converge the booking now and return it — never charge a second time.
|
||||
if (snapshot?.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
// A web↔mobile switch needs a different clientAction (Telebirr: REDIRECT for web
|
||||
// vs LAUNCH_APP for the native app). Detect the open session's platform from its
|
||||
// clientAction shape so a platform change is NOT blocked below: it must flow
|
||||
// through to re-initiate, where the payment service retires the stale session and
|
||||
// opens a fresh one with the correct launch method for the requested platform.
|
||||
const requestedMobile = (dto.platform ?? "web") === "mobile";
|
||||
const storedAction = (snapshot?.clientAction ??
|
||||
existingIntent.clientAction) as unknown as ClientAction | null;
|
||||
const platformChanged =
|
||||
(storedAction?.type === "LAUNCH_APP") !== requestedMobile;
|
||||
|
||||
// Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment
|
||||
// service was unreachable and the local status is non-terminal. Block the switch:
|
||||
// return the existing intent so the payer completes or waits out the open attempt
|
||||
// rather than opening a second concurrent charge. A platform switch is exempt — it
|
||||
// falls through so a session with the correct clientAction is opened for it.
|
||||
if (
|
||||
!platformChanged &&
|
||||
(!snapshot ||
|
||||
snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
|
||||
snapshot.status === ProviderPaymentStatus.PROCESSING)
|
||||
) {
|
||||
const intent = snapshot
|
||||
? await this.syncIntentProjection(booking.id, snapshot)
|
||||
: existingIntent;
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
// Otherwise the provider reports FAILED/CANCELLED, or the payer switched platform —
|
||||
// fall through and initiate the newly selected method below.
|
||||
}
|
||||
|
||||
// Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the
|
||||
// single passenger projection row (upserted by bookingId below) tracks the latest session.
|
||||
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
|
||||
const { returnUrl, failureUrl } = this.resolveReturnUrls(
|
||||
method,
|
||||
requestOrigin,
|
||||
@@ -324,15 +275,22 @@ export class PaymentsService {
|
||||
const paymentMethod = await this.prisma.paymentMethod.findUnique({
|
||||
where: { type: method },
|
||||
});
|
||||
const chargeCurrency = (
|
||||
paymentMethod?.currency ?? booking.currency
|
||||
).toUpperCase();
|
||||
const chargeCurrency =
|
||||
method === PaymentMethodType.CBE_BILL
|
||||
? "ETB"
|
||||
: (paymentMethod?.currency ?? booking.currency).toUpperCase();
|
||||
|
||||
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
|
||||
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
|
||||
|
||||
let chargeAmount: number;
|
||||
if (
|
||||
if (method === PaymentMethodType.CBE_BILL) {
|
||||
// Force ETB, no conversion (D8) — eligibility was already checked above.
|
||||
chargeAmount = this.currencyService.displayMinorToChargeMajor(
|
||||
booking.totalMinor,
|
||||
"ETB",
|
||||
);
|
||||
} else if (
|
||||
chargeCurrency === bookingDisplayCurrency &&
|
||||
chargeCurrency !== 'ETB' &&
|
||||
bookingDisplayTotalMinor != null
|
||||
@@ -350,6 +308,20 @@ export class PaymentsService {
|
||||
);
|
||||
}
|
||||
|
||||
// CBE_BILL: the bill lives in CBE's system for as long as the booking is payable, so the
|
||||
// intent expiry is the booking's own payment deadline — never a provider-session TTL
|
||||
// (plan §6.4); payerName feeds the mandatory Full_Name of CBE's query response.
|
||||
let payerName: string | undefined;
|
||||
let expiresAt: string | undefined;
|
||||
if (method === PaymentMethodType.CBE_BILL) {
|
||||
payerName =
|
||||
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
||||
booking.seats[0]?.passengerName;
|
||||
expiresAt = (
|
||||
await this.computeBookingPaymentDeadline(booking.id)
|
||||
)?.toISOString();
|
||||
}
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
@@ -362,6 +334,8 @@ export class PaymentsService {
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl,
|
||||
failureUrl,
|
||||
payerName,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
@@ -415,6 +389,97 @@ export class PaymentsService {
|
||||
return this.formatIntentStatus(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check +
|
||||
* payer identity for a booking. Called by the payment service while a CBE teller/app is
|
||||
* waiting — read-only and fast. This is the double-payment guard: once the booking is
|
||||
* confirmed by ANY method, stillPayable=false and CBE refuses the bill (§6.3).
|
||||
*/
|
||||
async billQuery(bookingId: string): Promise<BillQueryResponseDto> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { seats: true, passenger: { include: { user: true } } },
|
||||
});
|
||||
if (!booking) return { stillPayable: false, reason: "CANCELLED" };
|
||||
|
||||
const base = {
|
||||
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
|
||||
payerName:
|
||||
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
||||
booking.seats[0]?.passengerName ??
|
||||
booking.passenger?.user?.fullName ??
|
||||
null,
|
||||
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
|
||||
booking.totalMinor,
|
||||
"ETB",
|
||||
),
|
||||
currency: "ETB",
|
||||
};
|
||||
|
||||
if (booking.status === "CONFIRMED" || booking.paidAt) {
|
||||
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
|
||||
}
|
||||
if (booking.status !== "PENDING_PAYMENT") {
|
||||
return { ...base, stillPayable: false, reason: "CANCELLED" };
|
||||
}
|
||||
const deadline = await this.computeBookingPaymentDeadline(booking.id);
|
||||
if (deadline && deadline.getTime() < Date.now()) {
|
||||
return { ...base, stillPayable: false, reason: "EXPIRED" };
|
||||
}
|
||||
return { ...base, stillPayable: true, reason: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's
|
||||
* origin-segment time and that stop's own check-in window, falling back to the route default.
|
||||
*/
|
||||
private async computeBookingPaymentDeadline(
|
||||
bookingId: string,
|
||||
): Promise<Date | null> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: {
|
||||
createdAt: true,
|
||||
originStationId: true,
|
||||
schedule: {
|
||||
select: {
|
||||
departureAt: true,
|
||||
stopTimes: {
|
||||
select: {
|
||||
stationId: true,
|
||||
plannedArrivalAt: true,
|
||||
plannedDepartureAt: true,
|
||||
},
|
||||
},
|
||||
route: {
|
||||
select: {
|
||||
checkinMinutesBefore: true,
|
||||
stops: {
|
||||
select: { stationId: true, checkinMinutesBefore: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!booking?.schedule) return null;
|
||||
const originStop = booking.schedule.stopTimes?.find(
|
||||
(s) => s.stationId === booking.originStationId,
|
||||
);
|
||||
const dep = (originStop?.plannedArrivalAt ??
|
||||
originStop?.plannedDepartureAt ??
|
||||
booking.schedule.departureAt) as Date;
|
||||
const originRouteStop = booking.schedule.route?.stops?.find(
|
||||
(s) => s.stationId === booking.originStationId,
|
||||
);
|
||||
const checkinMinutes =
|
||||
originRouteStop?.checkinMinutesBefore ??
|
||||
booking.schedule.route?.checkinMinutesBefore ??
|
||||
undefined;
|
||||
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes);
|
||||
}
|
||||
|
||||
private resolveReturnUrls(
|
||||
method: PaymentMethodType,
|
||||
requestOrigin?: string | null,
|
||||
@@ -898,10 +963,76 @@ export class PaymentsService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the payment service (over HTTP — bypassing the possibly-down RabbitMQ) whether a booking is
|
||||
* actually paid, and CONFIRM it synchronously if so. Used by (a) every cancellation site as a
|
||||
* verify-before-cancel guard, and (b) the PaymentSyncService poller as lost-event recovery. Unlike
|
||||
* getIntentByReference, POST /payments/reconcile loops ALL intents and live-queries even
|
||||
* terminal (cancelled/expired) ones — so it catches a payment recorded only at the provider.
|
||||
*
|
||||
* - paid → the confirming payment is synced + finalized HERE (synchronously); the booking is
|
||||
* now CONFIRMED, so a cancellation caller must NOT cancel.
|
||||
* - not paid → verified unpaid; a cancellation caller may proceed.
|
||||
* - unverifiable (provider query errored, in-flight, or payment service unreachable) → a
|
||||
* cancellation caller must NOT cancel this cycle; defer and retry later.
|
||||
*/
|
||||
async reconcileAndConfirmIfPaid(
|
||||
bookingId: string,
|
||||
): Promise<{ paid: boolean; verified: boolean }> {
|
||||
const settlement = await this.paymentClient.reconcileByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
bookingId,
|
||||
);
|
||||
|
||||
if (settlement.unverifiable) {
|
||||
this.logger.warn(
|
||||
`reconcile-before-cancel: settlement UNVERIFIABLE for booking ${bookingId} — not cancelling`,
|
||||
);
|
||||
return { paid: false, verified: false };
|
||||
}
|
||||
|
||||
if (settlement.paid) {
|
||||
if (settlement.intent) {
|
||||
// Paid, but the confirm event may have been lost. Confirm synchronously (idempotent).
|
||||
const intent = await this.syncIntentProjection(
|
||||
bookingId,
|
||||
settlement.intent,
|
||||
);
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: settlement.intent.providerTxnId,
|
||||
paidAt: settlement.intent.paidAt
|
||||
? new Date(settlement.intent.paidAt)
|
||||
: undefined,
|
||||
}).catch((err) => {
|
||||
this.logger.error(
|
||||
`reconcile-before-cancel: finalize failed for booking ${bookingId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return { alreadyFinalized: false };
|
||||
});
|
||||
this.logger.log(
|
||||
`reconcile-before-cancel: booking ${bookingId} is PAID (${settlement.intent.merchantOrderId}) — confirmed, NOT cancelling`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`reconcile-before-cancel: booking ${bookingId} reported PAID but no intent snapshot — NOT cancelling`,
|
||||
);
|
||||
}
|
||||
return { paid: true, verified: true };
|
||||
}
|
||||
|
||||
// Verified not paid — safe to cancel.
|
||||
return { paid: false, verified: true };
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
/** Staff force-confirm: confirm the booking even if it is not PENDING_PAYMENT. */
|
||||
force?: boolean;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { id: input.intentId },
|
||||
@@ -911,32 +1042,35 @@ export class PaymentsService {
|
||||
// Idempotency guard — but still repair missing tickets. They can be absent
|
||||
// when the first finalization threw from generate() after the transaction
|
||||
// committed: the caller got a 500, retried, and now hits this early-return.
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
// Only repair for a CONFIRMED booking: a SUCCEEDED intent on a CANCELLED booking is a
|
||||
// recorded orphan payment (booking cancelled, seats possibly reassigned) and must NEVER
|
||||
// generate a ticket.
|
||||
const idempotencyBooking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (idempotencyBooking?.status === "CONFIRMED") {
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
||||
throw new BadRequestException(
|
||||
"PaymentIntent is cancelled; cannot finalize",
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
include: { seats: true },
|
||||
@@ -944,7 +1078,24 @@ export class PaymentsService {
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const paidAt = this.sanitizePaidAt(input.paidAt);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
|
||||
// Atomic confirm-once. A booking may have many intents (free method changes); only the FIRST
|
||||
// success on a still-PENDING_PAYMENT booking confirms it + generates the ticket. The conditional
|
||||
// update is the race guard: two simultaneous payments both reach here, but exactly one flips
|
||||
// PENDING_PAYMENT→CONFIRMED (count 1) — the other gets count 0 and is register-only (the payment
|
||||
// is already stored on the payment-api ledger; we don't confirm, don't ticket, don't mark this
|
||||
// row SUCCEEDED). `force` (staff) confirms regardless of the current booking status.
|
||||
const confirmed = await this.prisma.$transaction(async (tx) => {
|
||||
const res = input.force
|
||||
? await tx.booking.updateMany({
|
||||
where: { id: booking.id, status: { not: "CONFIRMED" } },
|
||||
data: { status: "CONFIRMED" },
|
||||
})
|
||||
: await tx.booking.updateMany({
|
||||
where: { id: booking.id, status: "PENDING_PAYMENT" },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
if (res.count === 0) return 0;
|
||||
await tx.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
@@ -952,14 +1103,23 @@ export class PaymentsService {
|
||||
providerTxnId:
|
||||
input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||||
paidAt,
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
},
|
||||
});
|
||||
await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
return res.count;
|
||||
});
|
||||
|
||||
if (confirmed === 0) {
|
||||
// Booking already confirmed by another payment (or not payable and not forced). This capture
|
||||
// is registered on the payment-api ledger; do not confirm, ticket, or touch this row.
|
||||
this.logger.error(
|
||||
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
|
||||
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
|
||||
);
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
} catch (err) {
|
||||
@@ -1087,38 +1247,93 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled
|
||||
// amount against the booking's display-currency total (the amount the customer agreed to pay);
|
||||
// a short payment must NOT confirm the booking. Amount-only — the display↔charge currency
|
||||
// divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor;
|
||||
const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01));
|
||||
if (event.amountMinor < expectedMinor - shortPayTolerance) {
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. `event.amountMinor`
|
||||
// carries the charge amount in MAJOR units (the intent's "real/major price" — what
|
||||
// initiate sent, e.g. 1500.00 ETB), while booking totals are stored in minor units, so
|
||||
// normalize before comparing; a short payment must NOT confirm the booking. Amount-only —
|
||||
// the display↔charge currency divergence is tracked separately under the USD/DJF
|
||||
// findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMajor = (booking.displayTotalMinor ?? booking.totalMinor) / 100;
|
||||
const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01);
|
||||
if (event.amountMinor < expectedMajor - shortPayTolerance) {
|
||||
this.logger.error(
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`,
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMajor} ${booking.displayCurrency}; not confirming`,
|
||||
);
|
||||
return { processed: false, reason: "amount-mismatch" };
|
||||
}
|
||||
|
||||
// Local intent row is a projection during the strangler migration: reuse it when the
|
||||
// legacy initiate path created one, otherwise materialize it from the event.
|
||||
let intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: event.referenceId },
|
||||
});
|
||||
if (!intent) {
|
||||
intent = await this.prisma.paymentIntent.create({
|
||||
data: {
|
||||
bookingId: event.referenceId,
|
||||
// Payment on an already-CANCELLED booking: record the success on the passenger projection too
|
||||
// (it is already registered on the payment-api ledger), but NEVER confirm the booking and NEVER
|
||||
// generate a ticket — the seats may already be held by another passenger. Refund is manual.
|
||||
if (booking.status === "CANCELLED") {
|
||||
await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: event.referenceId },
|
||||
update: {
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: this.sanitizePaidAt(
|
||||
event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
),
|
||||
},
|
||||
create: {
|
||||
bookingId: event.referenceId,
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: this.sanitizePaidAt(
|
||||
event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
),
|
||||
},
|
||||
});
|
||||
this.logger.error(
|
||||
`Payment on CANCELLED booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` +
|
||||
`txn=${event.providerTxnId ?? "n/a"}) — recorded on passenger + payment-api; NOT confirming ` +
|
||||
`(seats may be reassigned). Refund required.`,
|
||||
);
|
||||
return { processed: true, alreadyFinalized: true };
|
||||
}
|
||||
|
||||
// Sequential duplicate on an already-CONFIRMED booking: this success is a second payment,
|
||||
// already registered on the payment-api ledger. Do NOT touch the passenger row — it must keep
|
||||
// the confirming payment. (The concurrent-race case is caught atomically in finalizePaymentSuccess.)
|
||||
if (booking.status !== "PENDING_PAYMENT") {
|
||||
this.logger.error(
|
||||
`Duplicate capture on ${booking.status} booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` +
|
||||
`txn=${event.providerTxnId ?? "n/a"}) — registered in payment-api; not confirming`,
|
||||
);
|
||||
return { processed: true, alreadyFinalized: true };
|
||||
}
|
||||
|
||||
// Booking is payable — point the single passenger projection row at THIS paying session (so the
|
||||
// row reflects the payment that confirms the booking, even if the payer switched methods), then
|
||||
// finalize (which does the atomic confirm-once).
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: event.referenceId },
|
||||
update: {
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
},
|
||||
create: {
|
||||
bookingId: event.referenceId,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
},
|
||||
});
|
||||
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: event.providerTxnId,
|
||||
@@ -1174,6 +1389,7 @@ export class PaymentsService {
|
||||
return this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
|
||||
force: true,
|
||||
}).then(async (result) => {
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
|
||||
return result;
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule],
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
|
||||
// Retention windows
|
||||
@@ -28,6 +30,10 @@ export class TasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsClientService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
// ModuleRef (NOT direct injection): PaymentsService is request-scoped (AuditService injects
|
||||
// REQUEST), and injecting a request-scoped provider here would make TasksService request-scoped
|
||||
// too — which silently stops all its @Cron methods from firing. Resolve it per-tick instead.
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -290,6 +296,14 @@ export class TasksService {
|
||||
|
||||
let cancelledCount = 0;
|
||||
|
||||
// resolve() (not direct injection) because PaymentsService is request-scoped — same pattern
|
||||
// as PaymentSyncService. strict:false resolves it from the app context.
|
||||
const paymentsService = await this.moduleRef.resolve(
|
||||
PaymentsService,
|
||||
undefined,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
|
||||
@@ -308,6 +322,18 @@ export class TasksService {
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
|
||||
if (now < paymentDeadline) continue;
|
||||
|
||||
// Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded
|
||||
// event may have been lost (RabbitMQ down) or arrived late, leaving a paid booking stuck
|
||||
// PENDING_PAYMENT. Ask the payment service over HTTP; it confirms the booking synchronously
|
||||
// if paid. Only proceed to cancel when settlement is VERIFIED unpaid.
|
||||
const settlement = await paymentsService.reconcileAndConfirmIfPaid(booking.id);
|
||||
if (settlement.paid || !settlement.verified) {
|
||||
this.logger.log(
|
||||
`Skip auto-cancel ${booking.bookingRef}: ${settlement.paid ? 'PAID → confirmed' : 'unverifiable → deferred'}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1a. Release held seats (Journey rows are the occupancy source of truth once paid)
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.view)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@@ -99,7 +99,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get('by-order/:merchantOrderId')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.view)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket by merchant order ID',
|
||||
@@ -117,7 +117,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('scan-board/:qrCodeOrRef')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
||||
@@ -142,7 +142,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
@@ -173,7 +173,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.view)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Get validation logs for ticket' })
|
||||
getValidationLogs(@Param('ticketId') ticketId: string) {
|
||||
@@ -181,7 +181,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get('offline/export')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.view)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Export tickets for offline validation' })
|
||||
exportOfflineData(@Query('scheduleId') scheduleId: string) {
|
||||
@@ -189,7 +189,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('validate/offline')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Batch import offline validations',
|
||||
@@ -232,7 +232,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Patch(':id/restore')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
||||
restore(@Param('id') id: string) {
|
||||
|
||||
Reference in New Issue
Block a user