mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
1289 lines
46 KiB
TypeScript
1289 lines
46 KiB
TypeScript
import {
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
BadRequestException,
|
||
ConflictException,
|
||
} 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,
|
||
ForceConfirmDto,
|
||
} from "./payments.dto";
|
||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||
import {
|
||
PaymentClientService,
|
||
PaymentDiagnostic,
|
||
} from "./payment-client.service";
|
||
import { CurrencyService } from "../currency/currency.service";
|
||
import { AuditService } from "../../common/audit.service";
|
||
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
|
||
import {
|
||
PaymentService as PaymentServiceEnum,
|
||
PaymentReferenceType,
|
||
PaymentIntentSnapshot,
|
||
ProviderMethod,
|
||
ClientAction,
|
||
ProviderPaymentStatus,
|
||
} from "@edr/types";
|
||
|
||
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||
PaymentIntentStatus.REQUIRES_ACTION,
|
||
PaymentIntentStatus.PROCESSING,
|
||
PaymentIntentStatus.SUCCEEDED,
|
||
];
|
||
|
||
// Methods whose return/failure URLs are browser-facing pages on the passenger
|
||
// portal, so they should follow whichever domain the user came in on. DMONEY is
|
||
// deliberately excluded — its return URL is a server-to-server webhook host, not
|
||
// a page the browser lands on.
|
||
const DOMAIN_AWARE_METHODS = new Set<PaymentMethodType>([
|
||
PaymentMethodType.TELEBIRR,
|
||
PaymentMethodType.WAAFI,
|
||
]);
|
||
|
||
@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,
|
||
private auditService: AuditService,
|
||
) {}
|
||
|
||
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: {
|
||
select: {
|
||
bookingRef: true,
|
||
bookingType: true,
|
||
packageId: true,
|
||
priceTierId: true,
|
||
adultCount: true,
|
||
childCount: true,
|
||
totalMinor: true,
|
||
currency: true,
|
||
priceTier: { select: { priceMinor: true } },
|
||
},
|
||
},
|
||
},
|
||
skip,
|
||
take: pageSize,
|
||
orderBy: { createdAt: "desc" },
|
||
}),
|
||
this.prisma.paymentIntent.count({ where }),
|
||
]);
|
||
|
||
return {
|
||
items: items.map((item) => {
|
||
const b = item.booking as any;
|
||
// For package round-trip bookings the stored amountMinor may be the single-leg
|
||
// amount. Recompute from the tier price when applicable.
|
||
let amountMinor = item.amountMinor;
|
||
if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) {
|
||
const adultFare = b.priceTier.priceMinor * 2;
|
||
const childFare = Math.round(adultFare * 0.1);
|
||
const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
|
||
// Convert to the charge currency ratio: stored amountMinor is in charge currency
|
||
// (may be DJF/USD), but correctMinor is in ETB minor. Only override when the
|
||
// currency is ETB (most common case); for foreign currencies keep stored value.
|
||
if (item.currency === 'ETB') amountMinor = correctMinor;
|
||
}
|
||
return {
|
||
id: item.id,
|
||
reference: item.id.substring(0, 8),
|
||
bookingId: item.bookingId,
|
||
booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency },
|
||
amountMinor,
|
||
currency: item.currency,
|
||
method: item.method,
|
||
status: item.status,
|
||
createdAt: item.createdAt,
|
||
paidAt: item.paidAt,
|
||
};
|
||
}),
|
||
total,
|
||
page,
|
||
pageSize,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Returns the correct totalMinor (in ETB) for a booking, accounting for package round-trip
|
||
* bookings where totalMinor may have been stored as a single-leg amount before the server fix.
|
||
*/
|
||
private async resolveBookingTotal(booking: {
|
||
id: string;
|
||
totalMinor: number;
|
||
bookingType: string;
|
||
packageId?: string | null;
|
||
priceTierId?: string | null;
|
||
displayTotalMinor?: number | null;
|
||
}): Promise<number> {
|
||
if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') {
|
||
return booking.totalMinor;
|
||
}
|
||
// New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their
|
||
// totalMinor was already computed in ETB at creation time — no recomputation needed.
|
||
if (booking.displayTotalMinor != null && booking.displayTotalMinor > 0) {
|
||
return booking.totalMinor;
|
||
}
|
||
// Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier.
|
||
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } });
|
||
if (!tier) return booking.totalMinor;
|
||
const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } });
|
||
const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1;
|
||
const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length;
|
||
// tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is
|
||
// always in the same units as totalMinor (which is always the ETB canonical).
|
||
const rawFare = tier.priceMinor * 2;
|
||
const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB'
|
||
? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any)
|
||
: rawFare;
|
||
const childFareMinor = Math.round(adultFareMinor * 0.1);
|
||
return adultCount * adultFareMinor + childCount * childFareMinor;
|
||
}
|
||
|
||
async initiatePayment(
|
||
dto: InitiatePaymentDto,
|
||
requestOrigin?: string | null,
|
||
): 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;
|
||
|
||
// CAC Bank is an OTP debit — the bank SMSes the OTP to this number, so it's required.
|
||
if (method === PaymentMethodType.CAC_BANK && !dto.payerAccount?.trim()) {
|
||
throw new BadRequestException(
|
||
"payerAccount (mobile number) is required for CAC Bank",
|
||
);
|
||
}
|
||
|
||
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)
|
||
if (correctTotalMinor !== booking.totalMinor) {
|
||
await this.prisma.booking.update({
|
||
where: { id: booking.id },
|
||
data: { totalMinor: correctTotalMinor },
|
||
});
|
||
(booking as any).totalMinor = correctTotalMinor;
|
||
}
|
||
|
||
// 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,
|
||
requestOrigin,
|
||
);
|
||
|
||
// The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY
|
||
// settle in DJF, CARD in USD, Ethiopian wallets in ETB). When the booking's displayCurrency
|
||
// already matches the charge currency, use displayTotalMinor directly — the rate is already
|
||
// baked in at booking creation time. Only fall back to ETB→target conversion when they differ.
|
||
const paymentMethod = await this.prisma.paymentMethod.findUnique({
|
||
where: { type: method },
|
||
});
|
||
const chargeCurrency = (
|
||
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 (
|
||
chargeCurrency === bookingDisplayCurrency &&
|
||
chargeCurrency !== 'ETB' &&
|
||
bookingDisplayTotalMinor != null
|
||
) {
|
||
// Display currency matches charge currency — use the pre-converted amount directly.
|
||
chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency);
|
||
} else if (chargeCurrency === 'ETB') {
|
||
chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB');
|
||
} else {
|
||
// Booking is in ETB — convert to the provider's settlement currency.
|
||
chargeAmount = await this.currencyService.convertMinorToChargeMajor(
|
||
booking.totalMinor,
|
||
booking.currency,
|
||
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,
|
||
payerAccount: dto.payerAccount,
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by bookingId: the active
|
||
* remote intent is looked up by reference, the OTP is forwarded to the payment service,
|
||
* and the projection is refreshed. On success the booking is converged immediately
|
||
* (idempotent — the outbox → mark-paid path also converges it). A wrong/expired OTP
|
||
* bubbles up as a 400 so the payer can retry; the intent stays REQUIRES_ACTION.
|
||
*/
|
||
async confirmOtpPayment(
|
||
bookingId: string,
|
||
otp: string,
|
||
): Promise<IntentStatusDto> {
|
||
const snapshot = await this.paymentClient.getIntentByReference(
|
||
PaymentReferenceType.BOOKING,
|
||
bookingId,
|
||
);
|
||
if (!snapshot) {
|
||
throw new NotFoundException("No active payment to confirm for this booking");
|
||
}
|
||
|
||
const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp);
|
||
let intent = await this.syncIntentProjection(bookingId, confirmed);
|
||
|
||
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
||
await this.finalizePaymentSuccess({
|
||
intentId: intent.id,
|
||
providerTxnId: confirmed.providerTxnId,
|
||
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
|
||
});
|
||
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||
where: { id: intent.id },
|
||
});
|
||
}
|
||
|
||
return this.formatIntentStatus(intent);
|
||
}
|
||
|
||
private resolveReturnUrls(
|
||
method: PaymentMethodType,
|
||
requestOrigin?: string | null,
|
||
): {
|
||
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] ?? {};
|
||
let returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined;
|
||
let failureUrl =
|
||
m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl;
|
||
|
||
// For browser-facing methods, swap the configured URL's host for whichever
|
||
// allowlisted domain the user is currently on (bookingedr.et vs
|
||
// passenger.edrsc.com). `requestOrigin` is already validated against the
|
||
// allowlist by the controller; when it's null the configured URL is kept.
|
||
if (DOMAIN_AWARE_METHODS.has(method) && requestOrigin) {
|
||
returnUrl = rebaseUrlOrigin(returnUrl, requestOrigin);
|
||
failureUrl = rebaseUrlOrigin(failureUrl, requestOrigin);
|
||
}
|
||
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,
|
||
rawInitiation: (snapshot as any).providerResponse
|
||
? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
|
||
: Prisma.DbNull,
|
||
};
|
||
return this.prisma.paymentIntent.upsert({
|
||
where: { bookingId },
|
||
// amountMinor/currency are refreshed on update too: a cross-currency method switch
|
||
// (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection
|
||
// must reflect the currency the new provider actually charges — not the first one's.
|
||
update: {
|
||
...data,
|
||
amountMinor: snapshot.amountMinor,
|
||
currency: snapshot.currency,
|
||
},
|
||
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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001).
|
||
* Resolves the PNR to its booking id, then pulls the authoritative status from the payment
|
||
* microservice (via {@link getIntentByBookingId}).
|
||
*/
|
||
async getIntentByBookingRefOrId(
|
||
bookingRefOrId: string,
|
||
): Promise<IntentStatusDto> {
|
||
const bookingId = await this.resolveBookingId(bookingRefOrId);
|
||
return this.getIntentByBookingId(bookingId);
|
||
}
|
||
|
||
/**
|
||
* Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's
|
||
* stored intent row and a live provider status query, side by side ({ db, provider }).
|
||
* Pure read — does not reconcile or confirm the booking.
|
||
*/
|
||
async getPaymentDiagnosticByBookingRefOrId(
|
||
bookingRefOrId: string,
|
||
): Promise<PaymentDiagnostic> {
|
||
const bookingId = await this.resolveBookingId(bookingRefOrId);
|
||
return this.paymentClient.getDiagnosticByReference(
|
||
PaymentReferenceType.BOOKING,
|
||
bookingId,
|
||
);
|
||
}
|
||
|
||
/** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */
|
||
private async resolveBookingId(bookingRefOrId: string): Promise<string> {
|
||
const isUuid =
|
||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||
bookingRefOrId,
|
||
);
|
||
if (isUuid) return bookingRefOrId;
|
||
|
||
const booking = await this.prisma.booking.findUnique({
|
||
where: { bookingRef: bookingRefOrId },
|
||
select: { id: true },
|
||
});
|
||
if (!booking) {
|
||
throw new NotFoundException(`Booking not found: ${bookingRefOrId}`);
|
||
}
|
||
return booking.id;
|
||
}
|
||
|
||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||
const local = await this.prisma.paymentIntent.findUnique({
|
||
where: { bookingId },
|
||
});
|
||
|
||
|
||
if (local?.status === PaymentIntentStatus.SUCCEEDED) {
|
||
const booking = await this.prisma.booking.findUnique({
|
||
where: { id: bookingId },
|
||
select: { status: true },
|
||
});
|
||
if (booking?.status === "CONFIRMED") {
|
||
return this.formatIntentStatus(local);
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
providerResponse:
|
||
intent.rawInitiation && typeof intent.rawInitiation === "object"
|
||
? (intent.rawInitiation as Record<string, unknown>)
|
||
: 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" },
|
||
});
|
||
}
|
||
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'REFUNDED', bookingId: dto.bookingId } });
|
||
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: {
|
||
...(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,
|
||
bookingType: true,
|
||
packageId: true,
|
||
priceTierId: true,
|
||
currency: true,
|
||
displayCurrency: true,
|
||
displayTotalMinor: true,
|
||
},
|
||
});
|
||
if (!booking) throw new NotFoundException('Booking not found');
|
||
|
||
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
||
const requestedCurrency = currency.toUpperCase();
|
||
|
||
// Source of truth: displayTotalMinor in displayCurrency when available,
|
||
// otherwise totalMinor in ETB (bookings with no display currency override).
|
||
const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase();
|
||
const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor;
|
||
|
||
// Same currency — return directly, no conversion needed.
|
||
if (requestedCurrency === sourceCurrency) {
|
||
return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 };
|
||
}
|
||
|
||
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
||
where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any },
|
||
orderBy: { effectiveDate: 'desc' },
|
||
});
|
||
|
||
let rate: number;
|
||
if (exchangeRate) {
|
||
rate = Number(exchangeRate.rate);
|
||
} else {
|
||
// Try inverse rate
|
||
const inverseRate = await this.prisma.currencyExchangeRate.findFirst({
|
||
where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any },
|
||
orderBy: { effectiveDate: 'desc' },
|
||
});
|
||
if (inverseRate) {
|
||
rate = 1 / Number(inverseRate.rate);
|
||
} else {
|
||
// Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
|
||
rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any);
|
||
}
|
||
}
|
||
const converted = (sourceMinor / 100) * rate;
|
||
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) {
|
||
// 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.`,
|
||
);
|
||
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 },
|
||
});
|
||
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) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
// Only reassign seats when a *different* booking genuinely holds the seat
|
||
// (ConflictException). Any other error (transient DB issue, etc.) is logged
|
||
// and swallowed — the passenger keeps their original seat and the ticket can
|
||
// be retried via "Generate Missing" in the backoffice.
|
||
if (err instanceof ConflictException) {
|
||
this.logger.warn(
|
||
`Seat conflict for booking ${booking.id}: ${msg}. Attempting smart seat reassignment.`,
|
||
);
|
||
try {
|
||
await this.ticketsService.smartAssignAndGenerate(booking.id);
|
||
} catch (retryErr) {
|
||
this.logger.error(
|
||
`Smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||
);
|
||
}
|
||
} else {
|
||
this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`);
|
||
}
|
||
}
|
||
|
||
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 };
|
||
}
|
||
|
||
private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||
if (event.eventType === 'payment.failed') {
|
||
this.logger.warn(`supplementary charge ${event.referenceId} payment failed`);
|
||
return { processed: true };
|
||
}
|
||
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } });
|
||
if (!charge) {
|
||
this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`);
|
||
return { processed: false, reason: 'charge-not-found' };
|
||
}
|
||
if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true };
|
||
await this.prisma.supplementaryCharge.update({
|
||
where: { id: charge.id },
|
||
data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null },
|
||
});
|
||
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } });
|
||
return { processed: true };
|
||
}
|
||
|
||
async handlePaymentEvent(
|
||
event: PaymentEventDto,
|
||
): Promise<MarkPaidResponseDto> {
|
||
if (event.service !== PaymentServiceEnum.PASSENGER) {
|
||
this.logger.warn(
|
||
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
|
||
);
|
||
return { processed: false, reason: "foreign-reference" };
|
||
}
|
||
|
||
if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) {
|
||
return this.handleSupplementaryChargeEvent(event);
|
||
}
|
||
|
||
if (event.referenceType !== PaymentReferenceType.BOOKING) {
|
||
this.logger.warn(
|
||
`mark-paid: ignoring unknown referenceType ${event.referenceType}`,
|
||
);
|
||
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,
|
||
}).catch((err) => {
|
||
this.logger.error(
|
||
`finalizePaymentSuccess failed for booking ${event.referenceId}: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
return { alreadyFinalized: false };
|
||
});
|
||
return { processed: true, alreadyFinalized };
|
||
}
|
||
|
||
async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> {
|
||
const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } });
|
||
if (!booking) throw new NotFoundException('Booking not found');
|
||
|
||
const resolvedMethod = dto.paymentMethod
|
||
? (dto.paymentMethod as unknown as PaymentMethodType)
|
||
: PaymentMethodType.TELEBIRR;
|
||
|
||
let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } });
|
||
if (!intent) {
|
||
intent = await this.prisma.paymentIntent.create({
|
||
data: {
|
||
bookingId,
|
||
amountMinor: booking.totalMinor,
|
||
currency: booking.currency,
|
||
method: resolvedMethod,
|
||
status: PaymentIntentStatus.PROCESSING,
|
||
providerRef: `FORCE-${Date.now()}`,
|
||
providerTxnId: dto.paymentReference ?? null,
|
||
failureMessage: dto.notes ?? null,
|
||
},
|
||
});
|
||
} else {
|
||
// Update method/reference/notes regardless of current status
|
||
const updateData: any = {};
|
||
if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference;
|
||
if (dto.paymentMethod) updateData.method = resolvedMethod;
|
||
if (dto.notes) updateData.failureMessage = dto.notes;
|
||
if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) {
|
||
updateData.status = PaymentIntentStatus.PROCESSING;
|
||
}
|
||
if (Object.keys(updateData).length) {
|
||
intent = await this.prisma.paymentIntent.update({
|
||
where: { id: intent.id },
|
||
data: updateData,
|
||
});
|
||
}
|
||
}
|
||
|
||
return this.finalizePaymentSuccess({
|
||
intentId: intent.id,
|
||
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
|
||
}).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;
|
||
});
|
||
}
|
||
|
||
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 });
|
||
}
|
||
}
|
||
}
|