mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
901 lines
30 KiB
TypeScript
901 lines
30 KiB
TypeScript
import {
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
BadRequestException,
|
||
} from "@nestjs/common";
|
||
import { PrismaService } from "../../common/prisma.service";
|
||
import { SeatsService } from "../seats/seats.service";
|
||
import { TicketsService } from "../tickets/tickets.service";
|
||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||
import {
|
||
Prisma,
|
||
PaymentIntentStatus,
|
||
PaymentMethodType,
|
||
PaymentRegion,
|
||
} from "@prisma/client";
|
||
import {
|
||
InitiatePaymentDto,
|
||
RefundDto,
|
||
AddPaymentMethodDto,
|
||
InitiateResponseDto,
|
||
IntentStatusDto,
|
||
PaymentRegionEnum,
|
||
} from "./payments.dto";
|
||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||
import { PaymentClientService } from "./payment-client.service";
|
||
import { CurrencyService } from "../currency/currency.service";
|
||
import {
|
||
PaymentService as PaymentServiceEnum,
|
||
PaymentReferenceType,
|
||
PaymentIntentSnapshot,
|
||
ProviderMethod,
|
||
ClientAction,
|
||
ProviderPaymentStatus,
|
||
} from "@edr/types";
|
||
|
||
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||
PaymentIntentStatus.REQUIRES_ACTION,
|
||
PaymentIntentStatus.PROCESSING,
|
||
PaymentIntentStatus.SUCCEEDED,
|
||
];
|
||
|
||
@Injectable()
|
||
export class PaymentsService {
|
||
private readonly logger = new Logger(PaymentsService.name);
|
||
|
||
private readonly waafiDemoTrustReturn = true;
|
||
|
||
constructor(
|
||
private prisma: PrismaService,
|
||
private seatsService: SeatsService,
|
||
private ticketsService: TicketsService,
|
||
private eventEmitter: EventEmitter2,
|
||
private paymentClient: PaymentClientService,
|
||
private currencyService: CurrencyService,
|
||
) {}
|
||
|
||
async deletePayment(id: string) {
|
||
const intent = await this.prisma.paymentIntent.findUnique({ where: { id } });
|
||
if (!intent) throw new NotFoundException('Payment intent not found');
|
||
await this.prisma.paymentIntent.delete({ where: { id } });
|
||
return { deleted: true, id };
|
||
}
|
||
|
||
async getAll(filters: {
|
||
search?: string;
|
||
status?: string;
|
||
method?: string;
|
||
page?: number;
|
||
pageSize?: number;
|
||
}) {
|
||
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
||
const skip = (page - 1) * pageSize;
|
||
|
||
const where: any = {};
|
||
if (search) {
|
||
where.OR = [
|
||
{ id: { contains: search, mode: "insensitive" } },
|
||
{ booking: { bookingRef: { contains: search, mode: "insensitive" } } },
|
||
];
|
||
}
|
||
if (status) {
|
||
where.status = status;
|
||
}
|
||
if (method) {
|
||
where.method = method;
|
||
}
|
||
|
||
const [items, total] = await Promise.all([
|
||
this.prisma.paymentIntent.findMany({
|
||
where,
|
||
include: { booking: true },
|
||
skip,
|
||
take: pageSize,
|
||
orderBy: { createdAt: "desc" },
|
||
}),
|
||
this.prisma.paymentIntent.count({ where }),
|
||
]);
|
||
|
||
return {
|
||
items: items.map((item) => ({
|
||
id: item.id,
|
||
reference: item.id.substring(0, 8),
|
||
bookingId: item.bookingId,
|
||
booking: { bookingRef: item.booking?.bookingRef },
|
||
amountMinor: item.amountMinor,
|
||
currency: item.currency,
|
||
method: item.method,
|
||
status: item.status,
|
||
createdAt: item.createdAt,
|
||
paidAt: item.paidAt,
|
||
})),
|
||
total,
|
||
page,
|
||
pageSize,
|
||
};
|
||
}
|
||
|
||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||
const booking = await this.prisma.booking.findUnique({
|
||
where: { id: dto.bookingId },
|
||
include: { seats: true },
|
||
});
|
||
if (!booking) throw new NotFoundException("Booking not found");
|
||
if (booking.status !== "PENDING_PAYMENT") {
|
||
throw new BadRequestException("Booking not payable");
|
||
}
|
||
|
||
const method = dto.method as PaymentMethodType;
|
||
|
||
// WALLET is an internal balance debit — it never leaves this app.
|
||
if (method === PaymentMethodType.WALLET) {
|
||
const existing = await this.prisma.paymentIntent.findUnique({
|
||
where: { bookingId: dto.bookingId },
|
||
});
|
||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||
return this.formatIntentResponse(existing);
|
||
}
|
||
return this.initiateWalletPayment(booking);
|
||
}
|
||
|
||
const { returnUrl, failureUrl } = this.resolveReturnUrls(method);
|
||
|
||
// The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY
|
||
// settle in DJF, CARD in USD, Ethiopian wallets in ETB). Convert the ETB booking total into
|
||
// that currency here so the payment microservice stays currency-agnostic and charges it as-is.
|
||
const paymentMethod = await this.prisma.paymentMethod.findUnique({
|
||
where: { type: method },
|
||
});
|
||
const chargeCurrency = (
|
||
paymentMethod?.currency ?? booking.currency
|
||
).toUpperCase();
|
||
const chargeAmount = await this.currencyService.convertEtbMinorToChargeMajor(
|
||
booking.totalMinor,
|
||
chargeCurrency,
|
||
);
|
||
|
||
const snapshot = await this.paymentClient.initiate({
|
||
service: PaymentServiceEnum.PASSENGER,
|
||
referenceType: PaymentReferenceType.BOOKING,
|
||
referenceId: booking.id,
|
||
orderRef: booking.bookingRef,
|
||
amountMinor: chargeAmount,
|
||
currency: chargeCurrency,
|
||
provider: method as unknown as ProviderMethod,
|
||
platform: dto.platform,
|
||
returnUrl,
|
||
failureUrl,
|
||
});
|
||
|
||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||
// Already-paid order re-initiated: converge the booking now (idempotent).
|
||
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);
|
||
}
|
||
private resolveReturnUrls(method: PaymentMethodType): {
|
||
returnUrl?: string;
|
||
failureUrl?: string;
|
||
} {
|
||
const perMethod: Partial<
|
||
Record<PaymentMethodType, { returnUrl?: string; failureUrl?: string }>
|
||
> = {
|
||
[PaymentMethodType.TELEBIRR]: {
|
||
returnUrl: process.env.TELEBIRR_RETURN_URL,
|
||
},
|
||
[PaymentMethodType.WAAFI]: {
|
||
returnUrl: process.env.WAAFI_SUCCESS_REDIRECT,
|
||
failureUrl: process.env.WAAFI_FAIL_REDIRECT,
|
||
},
|
||
[PaymentMethodType.DMONEY]: {
|
||
returnUrl: process.env.DMONEY_RETURN_URL,
|
||
},
|
||
[PaymentMethodType.CBE_BIRR]: {
|
||
returnUrl: process.env.CBE_RETURN_URL,
|
||
},
|
||
[PaymentMethodType.EBIRR]: {
|
||
returnUrl: process.env.EBIRR_RETURN_URL,
|
||
},
|
||
[PaymentMethodType.CARD]: {
|
||
returnUrl: process.env.CARD_RETURN_URL,
|
||
},
|
||
};
|
||
|
||
const m = perMethod[method] ?? {};
|
||
const returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined;
|
||
const failureUrl =
|
||
m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl;
|
||
return { returnUrl, failureUrl };
|
||
}
|
||
|
||
async confirmWaafiReturnDemo(params: {
|
||
referenceId?: string;
|
||
state?: string;
|
||
transactionId?: string;
|
||
}): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> {
|
||
if (!this.waafiDemoTrustReturn) {
|
||
return { confirmed: false, reason: "demo-disabled" };
|
||
}
|
||
if ((params.state ?? "").toUpperCase() !== "APPROVED") {
|
||
return { confirmed: false, reason: `not-approved (${params.state})` };
|
||
}
|
||
if (!params.referenceId) {
|
||
return { confirmed: false, reason: "missing-referenceId" };
|
||
}
|
||
|
||
const intent = await this.prisma.paymentIntent.findFirst({
|
||
where: { merchantOrderId: params.referenceId },
|
||
});
|
||
if (!intent) {
|
||
this.logger.warn(
|
||
`waafi demo return: no local intent for referenceId ${params.referenceId}`,
|
||
);
|
||
return { confirmed: false, reason: "intent-not-found" };
|
||
}
|
||
|
||
this.logger.warn(
|
||
`WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`,
|
||
);
|
||
await this.finalizePaymentSuccess({
|
||
intentId: intent.id,
|
||
providerTxnId: params.transactionId,
|
||
});
|
||
return { confirmed: true, bookingId: intent.bookingId };
|
||
}
|
||
|
||
private async syncIntentProjection(
|
||
bookingId: string,
|
||
snapshot: PaymentIntentSnapshot,
|
||
) {
|
||
const status =
|
||
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||
? PaymentIntentStatus.PROCESSING
|
||
: (snapshot.status as unknown as PaymentIntentStatus);
|
||
const data = {
|
||
status,
|
||
method: snapshot.provider as unknown as PaymentMethodType,
|
||
merchantOrderId: snapshot.merchantOrderId,
|
||
clientAction: snapshot.clientAction
|
||
? (snapshot.clientAction as unknown as Prisma.InputJsonValue)
|
||
: Prisma.DbNull,
|
||
providerTxnId: snapshot.providerTxnId ?? null,
|
||
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
|
||
failureCode: snapshot.failureCode ?? null,
|
||
failureMessage: snapshot.failureMessage ?? null,
|
||
};
|
||
return this.prisma.paymentIntent.upsert({
|
||
where: { bookingId },
|
||
update: data,
|
||
create: {
|
||
bookingId,
|
||
amountMinor: snapshot.amountMinor,
|
||
currency: snapshot.currency,
|
||
...data,
|
||
},
|
||
});
|
||
}
|
||
|
||
private async initiateWalletPayment(
|
||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||
): Promise<InitiateResponseDto> {
|
||
const debitResult = await this.prisma.$transaction(async (tx) => {
|
||
const wallet = await tx.walletAccount.findUnique({
|
||
where: { passengerId: booking.passengerId },
|
||
});
|
||
if (!wallet || wallet.balanceMinor < booking.totalMinor) {
|
||
return { success: false };
|
||
}
|
||
const newBalance = wallet.balanceMinor - booking.totalMinor;
|
||
await tx.walletAccount.update({
|
||
where: { passengerId: booking.passengerId },
|
||
data: { balanceMinor: newBalance },
|
||
});
|
||
await tx.walletLedgerEntry.create({
|
||
data: {
|
||
walletId: wallet.id,
|
||
type: "DEBIT",
|
||
amountMinor: booking.totalMinor,
|
||
balanceAfterMinor: newBalance,
|
||
description: `Train Ticket - ${booking.bookingRef}`,
|
||
relatedBookingId: booking.id,
|
||
},
|
||
});
|
||
return { success: true };
|
||
});
|
||
|
||
if (!debitResult.success) {
|
||
const failed = await this.prisma.paymentIntent.upsert({
|
||
where: { bookingId: booking.id },
|
||
update: {
|
||
status: PaymentIntentStatus.FAILED,
|
||
failureCode: "INSUFFICIENT_BALANCE",
|
||
},
|
||
create: {
|
||
bookingId: booking.id,
|
||
amountMinor: booking.totalMinor,
|
||
method: PaymentMethodType.WALLET,
|
||
status: PaymentIntentStatus.FAILED,
|
||
failureCode: "INSUFFICIENT_BALANCE",
|
||
},
|
||
});
|
||
return this.formatIntentResponse(failed);
|
||
}
|
||
|
||
const intent = await this.prisma.paymentIntent.upsert({
|
||
where: { bookingId: booking.id },
|
||
update: { status: PaymentIntentStatus.PROCESSING },
|
||
create: {
|
||
bookingId: booking.id,
|
||
amountMinor: booking.totalMinor,
|
||
method: PaymentMethodType.WALLET,
|
||
status: PaymentIntentStatus.PROCESSING,
|
||
providerRef: `WALLET-${Date.now()}`,
|
||
},
|
||
});
|
||
await this.finalizePaymentSuccess({ intentId: intent.id });
|
||
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||
where: { id: intent.id },
|
||
});
|
||
return this.formatIntentResponse(refreshed);
|
||
}
|
||
|
||
private formatIntentResponse(
|
||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||
): InitiateResponseDto {
|
||
const clientAction =
|
||
intent.clientAction && typeof intent.clientAction === "object"
|
||
? (intent.clientAction as unknown as ClientAction)
|
||
: undefined;
|
||
return {
|
||
intentId: intent.id,
|
||
status: intent.status,
|
||
clientAction,
|
||
merchantOrderId: intent.merchantOrderId ?? undefined,
|
||
};
|
||
}
|
||
|
||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||
const local = await this.prisma.paymentIntent.findUnique({
|
||
where: { bookingId },
|
||
});
|
||
|
||
// WALLET payments never leave this app — no remote intent exists for them.
|
||
if (local?.method === PaymentMethodType.WALLET) {
|
||
return this.formatIntentStatus(local);
|
||
}
|
||
|
||
// Pull/reconcile through the payment microservice (it refreshes stale intents from the
|
||
// provider itself). Falls back to the legacy local path when the service is unreachable
|
||
// or only a pre-cutover local intent exists.
|
||
let snapshot: PaymentIntentSnapshot | null = null;
|
||
try {
|
||
snapshot = await this.paymentClient.getIntentByReference(
|
||
PaymentReferenceType.BOOKING,
|
||
bookingId,
|
||
);
|
||
} 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`,
|
||
);
|
||
}
|
||
|
||
if (!snapshot) {
|
||
// Pre-cutover/local-only intent (or service briefly unreachable): serve the cached
|
||
// status. The payment service owns provider refresh for everything initiated after
|
||
// the cutover; webhooks/mark-paid converge the rest.
|
||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||
return this.formatIntentStatus(local);
|
||
}
|
||
|
||
let intent = await this.syncIntentProjection(bookingId, snapshot);
|
||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||
// Poll observed success before (or instead of) the mark-paid event — converge now.
|
||
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.formatIntentStatus(intent);
|
||
}
|
||
|
||
private formatIntentStatus(
|
||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||
): IntentStatusDto {
|
||
const base = this.formatIntentResponse(intent);
|
||
return {
|
||
...base,
|
||
paidAt: intent.paidAt?.toISOString(),
|
||
failureCode: intent.failureCode ?? undefined,
|
||
failureMessage: intent.failureMessage ?? undefined,
|
||
};
|
||
}
|
||
|
||
async refund(dto: RefundDto) {
|
||
const intent = await this.prisma.paymentIntent.findUnique({
|
||
where: { bookingId: dto.bookingId },
|
||
});
|
||
if (!intent || intent.status !== "SUCCEEDED")
|
||
throw new BadRequestException("No successful payment to refund");
|
||
await this.prisma.paymentIntent.update({
|
||
where: { bookingId: dto.bookingId },
|
||
data: { status: "CANCELLED" },
|
||
});
|
||
const booking = await this.prisma.booking.findUnique({
|
||
where: { id: dto.bookingId },
|
||
include: { seats: true },
|
||
});
|
||
if (booking) {
|
||
await this.seatsService.releaseSeats(booking.id);
|
||
await this.prisma.booking.update({
|
||
where: { id: dto.bookingId },
|
||
data: { status: "CANCELLED" },
|
||
});
|
||
}
|
||
return { refunded: true, bookingRef: booking?.bookingRef };
|
||
}
|
||
|
||
addPaymentMethod(dto: AddPaymentMethodDto) {
|
||
const data = {
|
||
type: dto.type as unknown as PaymentMethodType,
|
||
displayName: dto.displayName,
|
||
region: dto.region as unknown as PaymentRegion,
|
||
currency: dto.currency ?? "ETB",
|
||
providerId: dto.providerId,
|
||
enabled: dto.enabled ?? true,
|
||
sortOrder: dto.sortOrder ?? 0,
|
||
};
|
||
return this.prisma.paymentMethod.upsert({
|
||
where: { type: data.type },
|
||
update: data,
|
||
create: data,
|
||
});
|
||
}
|
||
|
||
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
|
||
const existing = await this.prisma.paymentMethod.findUnique({ where: { id } });
|
||
if (!existing) throw new NotFoundException('Payment method not found');
|
||
|
||
const updateData: any = {};
|
||
if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
|
||
if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion;
|
||
if (dto.currency !== undefined) updateData.currency = dto.currency;
|
||
if (dto.providerId !== undefined) updateData.providerId = dto.providerId;
|
||
if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
|
||
if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
|
||
|
||
return this.prisma.paymentMethod.update({
|
||
where: { id },
|
||
data: updateData,
|
||
});
|
||
}
|
||
|
||
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
||
return this.prisma.paymentMethod.findMany({
|
||
where: {
|
||
enabled: true,
|
||
...(region
|
||
? {
|
||
region: {
|
||
in: [
|
||
region,
|
||
PaymentRegionEnum.GLOBAL,
|
||
] as unknown as PaymentRegion[],
|
||
},
|
||
}
|
||
: {}),
|
||
},
|
||
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
|
||
});
|
||
}
|
||
|
||
async getBookingAmountByCurrency(
|
||
bookingId: string,
|
||
currency: string,
|
||
): Promise<{ booking_id: string; currency: string; amount: number }> {
|
||
const booking = await this.prisma.booking.findUnique({
|
||
where: { id: bookingId },
|
||
select: { id: true, totalMinor: true },
|
||
});
|
||
if (!booking) throw new NotFoundException('Booking not found');
|
||
|
||
const requestedCurrency = currency.toUpperCase();
|
||
const amountInETB = booking.totalMinor / 100;
|
||
|
||
if (requestedCurrency === 'ETB') {
|
||
return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
|
||
}
|
||
|
||
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
||
where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any },
|
||
orderBy: { effectiveDate: 'desc' },
|
||
});
|
||
if (!exchangeRate) {
|
||
throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`);
|
||
}
|
||
|
||
const rate = Number(exchangeRate.rate);
|
||
const converted = parseFloat((amountInETB * rate).toFixed(2));
|
||
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
|
||
}
|
||
|
||
/**
|
||
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
|
||
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
|
||
* whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the
|
||
* booking still confirms.
|
||
*/
|
||
private sanitizePaidAt(value?: Date): Date {
|
||
const now = new Date();
|
||
if (!value) return now;
|
||
const t = value.getTime();
|
||
const oneDayMs = 86_400_000;
|
||
if (
|
||
Number.isNaN(t) ||
|
||
t > now.getTime() + oneDayMs ||
|
||
t < Date.UTC(2000, 0, 1)
|
||
) {
|
||
this.logger.warn(
|
||
`finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`,
|
||
);
|
||
return now;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
async finalizePaymentSuccess(input: {
|
||
intentId: string;
|
||
providerTxnId?: string;
|
||
paidAt?: Date;
|
||
}): Promise<{ alreadyFinalized: boolean }> {
|
||
const intent = await this.prisma.paymentIntent.findUnique({
|
||
where: { id: input.intentId },
|
||
});
|
||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
|
||
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 },
|
||
});
|
||
if (!booking) throw new NotFoundException("Booking not found");
|
||
|
||
const paidAt = this.sanitizePaidAt(input.paidAt);
|
||
await this.prisma.$transaction(async (tx) => {
|
||
await tx.paymentIntent.update({
|
||
where: { id: intent.id },
|
||
data: {
|
||
status: PaymentIntentStatus.SUCCEEDED,
|
||
providerTxnId:
|
||
input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||
paidAt,
|
||
},
|
||
});
|
||
await tx.booking.update({
|
||
where: { id: booking.id },
|
||
data: { status: "CONFIRMED" },
|
||
});
|
||
});
|
||
|
||
try {
|
||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
}
|
||
|
||
try {
|
||
await this.createJourneySegments(booking);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
}
|
||
|
||
try {
|
||
await this.ticketsService.generate(booking.id);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
throw err;
|
||
}
|
||
|
||
try {
|
||
await this.awardLoyaltyPoints(
|
||
booking.passengerId,
|
||
booking.totalMinor,
|
||
booking.id,
|
||
);
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
}
|
||
|
||
this.eventEmitter.emit("payment.succeeded", { booking });
|
||
return { alreadyFinalized: false };
|
||
}
|
||
|
||
async handlePaymentEvent(
|
||
event: PaymentEventDto,
|
||
): Promise<MarkPaidResponseDto> {
|
||
if (
|
||
event.service !== PaymentServiceEnum.PASSENGER ||
|
||
event.referenceType !== PaymentReferenceType.BOOKING
|
||
) {
|
||
this.logger.warn(
|
||
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
|
||
);
|
||
return { processed: false, reason: "foreign-reference" };
|
||
}
|
||
|
||
if (event.eventType === "payment.failed") {
|
||
const intent = await this.prisma.paymentIntent.findUnique({
|
||
where: { bookingId: event.referenceId },
|
||
});
|
||
if (intent) {
|
||
await this.markPaymentFailed({
|
||
intentId: intent.id,
|
||
failureCode: event.failureCode,
|
||
failureMessage: event.failureMessage,
|
||
});
|
||
}
|
||
const failedBooking = await this.prisma.booking.findUnique({
|
||
where: { id: event.referenceId },
|
||
});
|
||
if (failedBooking) {
|
||
this.eventEmitter.emit("payment.failed", { booking: failedBooking });
|
||
}
|
||
return { processed: true };
|
||
}
|
||
|
||
const booking = await this.prisma.booking.findUnique({
|
||
where: { id: event.referenceId },
|
||
});
|
||
if (!booking) {
|
||
// Ack (200) — a missing booking will not appear on redelivery; needs investigation.
|
||
this.logger.error(
|
||
`mark-paid: no booking for reference ${event.referenceId}`,
|
||
);
|
||
return { processed: false, reason: "booking-not-found" };
|
||
}
|
||
|
||
// 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,
|
||
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,
|
||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||
});
|
||
return { processed: true, alreadyFinalized };
|
||
}
|
||
|
||
async markPaymentFailed(input: {
|
||
intentId: string;
|
||
failureCode?: string;
|
||
failureMessage?: string;
|
||
}): Promise<void> {
|
||
const intent = await this.prisma.paymentIntent.findUnique({
|
||
where: { id: input.intentId },
|
||
});
|
||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||
if (
|
||
intent.status === PaymentIntentStatus.SUCCEEDED ||
|
||
intent.status === PaymentIntentStatus.CANCELLED
|
||
) {
|
||
return;
|
||
}
|
||
await this.prisma.paymentIntent.update({
|
||
where: { id: intent.id },
|
||
data: {
|
||
status: PaymentIntentStatus.FAILED,
|
||
failureCode: input.failureCode,
|
||
failureMessage: input.failureMessage,
|
||
},
|
||
});
|
||
}
|
||
|
||
private async awardLoyaltyPoints(
|
||
passengerId: string,
|
||
amountMinor: number,
|
||
bookingId: string,
|
||
) {
|
||
const points = Math.floor(amountMinor / 100);
|
||
const account = await this.prisma.loyaltyAccount.findUnique({
|
||
where: { passengerId },
|
||
});
|
||
if (!account) return;
|
||
const newBalance = account.pointsBalance + points;
|
||
const tier =
|
||
newBalance >= 10000
|
||
? "PLATINUM"
|
||
: newBalance >= 5000
|
||
? "GOLD"
|
||
: newBalance >= 2000
|
||
? "SILVER"
|
||
: "BRONZE";
|
||
await this.prisma.loyaltyAccount.update({
|
||
where: { passengerId },
|
||
data: { pointsBalance: { increment: points }, tier: tier as any },
|
||
});
|
||
await this.prisma.loyaltyLedgerEntry.create({
|
||
data: {
|
||
accountId: account.id,
|
||
delta: points,
|
||
reason: "TRIP_COMPLETED",
|
||
bookingId,
|
||
balanceAfter: newBalance,
|
||
},
|
||
});
|
||
}
|
||
|
||
private async createJourneySegments(
|
||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||
) {
|
||
const b = booking as any;
|
||
|
||
// Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] }
|
||
// BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2
|
||
type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] };
|
||
const legDefs: LegDef[] = [];
|
||
|
||
const seatsForLeg = (legNum: number) =>
|
||
booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId);
|
||
|
||
if (booking.bookingType === 'ONE_WAY') {
|
||
legDefs.push({
|
||
scheduleId: booking.scheduleId,
|
||
originStationId: b.originStationId,
|
||
destinationStationId: b.destinationStationId,
|
||
seatIds: booking.seats.map((s: any) => s.seatId),
|
||
});
|
||
} else if (booking.bookingType === 'ROUND_TRIP') {
|
||
legDefs.push({
|
||
scheduleId: booking.scheduleId,
|
||
originStationId: b.originStationId,
|
||
destinationStationId: b.destinationStationId,
|
||
seatIds: seatsForLeg(1),
|
||
});
|
||
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
|
||
legDefs.push({
|
||
scheduleId: b.returnScheduleId,
|
||
originStationId: b.returnOriginStationId,
|
||
destinationStationId: b.returnDestinationStationId,
|
||
seatIds: seatsForLeg(2),
|
||
});
|
||
}
|
||
} else if (booking.bookingType === 'TRANSIT') {
|
||
legDefs.push({
|
||
scheduleId: booking.scheduleId,
|
||
originStationId: b.originStationId,
|
||
destinationStationId: b.leg2OriginStationId, // transit station
|
||
seatIds: seatsForLeg(1),
|
||
});
|
||
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
|
||
legDefs.push({
|
||
scheduleId: b.leg2ScheduleId,
|
||
originStationId: b.leg2OriginStationId,
|
||
destinationStationId: b.leg2DestinationStationId,
|
||
seatIds: seatsForLeg(2),
|
||
});
|
||
}
|
||
} else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') {
|
||
legDefs.push({
|
||
scheduleId: booking.scheduleId,
|
||
originStationId: b.originStationId,
|
||
destinationStationId: b.leg2OriginStationId,
|
||
seatIds: seatsForLeg(1),
|
||
});
|
||
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
|
||
legDefs.push({
|
||
scheduleId: b.leg2ScheduleId,
|
||
originStationId: b.leg2OriginStationId,
|
||
destinationStationId: b.leg2DestinationStationId,
|
||
seatIds: seatsForLeg(2),
|
||
});
|
||
}
|
||
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
|
||
legDefs.push({
|
||
scheduleId: b.returnScheduleId,
|
||
originStationId: b.returnOriginStationId,
|
||
destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId,
|
||
seatIds: seatsForLeg(3),
|
||
});
|
||
}
|
||
if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) {
|
||
legDefs.push({
|
||
scheduleId: b.returnLeg2ScheduleId,
|
||
originStationId: b.returnLeg2OriginStationId,
|
||
destinationStationId: b.returnLeg2DestStationId,
|
||
seatIds: seatsForLeg(4),
|
||
});
|
||
}
|
||
}
|
||
|
||
if (legDefs.length === 0) return;
|
||
|
||
const journey = await this.prisma.journey.create({
|
||
data: {
|
||
passengerId: booking.passengerId,
|
||
bookingId: booking.id,
|
||
status: 'CONFIRMED',
|
||
totalMinor: booking.totalMinor,
|
||
currency: booking.currency,
|
||
} as any,
|
||
});
|
||
|
||
const journeySegments: any[] = [];
|
||
let segmentOrder = 0;
|
||
|
||
for (const leg of legDefs) {
|
||
if (leg.seatIds.length === 0) continue;
|
||
|
||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||
where: { scheduleId: leg.scheduleId },
|
||
orderBy: { sequence: 'asc' },
|
||
select: { stationId: true, sequence: true },
|
||
});
|
||
|
||
const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId);
|
||
const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId);
|
||
if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue;
|
||
|
||
for (const seatId of leg.seatIds) {
|
||
for (let i = originIdx; i < destIdx; i++) {
|
||
journeySegments.push({
|
||
journeyId: journey.id,
|
||
scheduleId: leg.scheduleId,
|
||
segmentOrder: segmentOrder++,
|
||
seatId,
|
||
departureStationId: stopTimes[i].stationId,
|
||
arrivalStationId: stopTimes[i + 1].stationId,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (journeySegments.length > 0) {
|
||
await this.prisma.journeySegment.createMany({ data: journeySegments });
|
||
}
|
||
}
|
||
}
|