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 { 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 walletDemoAutoSucceed = true; private readonly waafiDemoTrustReturn = true; constructor( private prisma: PrismaService, private seatsService: SeatsService, private ticketsService: TicketsService, private eventEmitter: EventEmitter2, private paymentClient: PaymentClientService, ) {} 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 { 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); const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: PaymentReferenceType.BOOKING, referenceId: booking.id, orderRef: booking.bookingRef, // Send the REAL (major) price, not minor units. The payment API no longer divides by 100 // (freight already passes the real price), so the providers charge this value as-is. amountMinor: booking.totalMinor / 100, currency: booking.currency, 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.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 { // DEMO ONLY (WALLET_DEMO_AUTO_SUCCEED): pretend the payment succeeded — no balance check, // no debit — and run the exact same finalize path a real successful payment uses // (booking → CONFIRMED, seats confirmed, ticket issued). Remove once a real provider works. if (this.walletDemoAutoSucceed) { this.logger.warn( `WALLET_DEMO_AUTO_SUCCEED enabled — faking a successful WALLET payment for booking ${booking.bookingRef} (${booking.id})`, ); const demoIntent = await this.prisma.paymentIntent.upsert({ where: { bookingId: booking.id }, update: { status: PaymentIntentStatus.PROCESSING, failureCode: null, method: PaymentMethodType.WALLET, }, create: { bookingId: booking.id, amountMinor: booking.totalMinor, method: PaymentMethodType.WALLET, status: PaymentIntentStatus.PROCESSING, providerRef: `WALLET-DEMO-${Date.now()}`, }, }); await this.finalizePaymentSuccess({ intentId: demoIntent.id }); const settled = await this.prisma.paymentIntent.findUniqueOrThrow({ where: { id: demoIntent.id }, }); return this.formatIntentResponse(settled); } 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>, ): 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 { 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>, ): 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, }); } 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" }], }); } /** * 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 { 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" }; } // The event carries the REAL (major) price the provider charged (passenger now sends // booking.totalMinor/100 on initiate), so convert it back to minor units before comparing // with booking.totalMinor (which is in minor units). const eventAmountMinor = Math.round(event.amountMinor * 100); if (booking.totalMinor !== eventAmountMinor) { // Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED, // which is the alertable signal for an asserted-vs-paid amount divergence. this.logger.error( `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor} (=${eventAmountMinor} minor)`, ); throw new BadRequestException( "Event amount does not match booking total", ); } // 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 { 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, }, }); 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 }); } } }