From 1af5d6d310a1e205ef6d82a84f1edf87e9323e22 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 25 Jun 2026 15:50:27 +0300 Subject: [PATCH] Update seatmap and add cron job for payment status --- .../migration.sql | 2 + .../migration.sql | 1 + apps/edr-passenger-api/prisma/schema.prisma | 1 + apps/edr-passenger-api/src/app.module.ts | 2 + .../common/filters/http-exception.filter.ts | 6 + .../src/modules/fleet/fleet.service.ts | 2 +- .../src/modules/seats/seats.service.ts | 83 ++++--- .../src/modules/tasks/tasks.module.ts | 10 + .../src/modules/tasks/tasks.service.ts | 205 ++++++++++++++++++ .../src/modules/tickets/tickets.service.ts | 37 +++- apps/edr-passenger-api/tsconfig.json | 8 +- .../portal/src/app/booking/payment/page.tsx | 53 ++--- 12 files changed, 339 insertions(+), 71 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/tasks/tasks.module.ts create mode 100644 apps/edr-passenger-api/src/modules/tasks/tasks.service.ts diff --git a/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql new file mode 100644 index 000000000..435d95829 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql @@ -0,0 +1,2 @@ +-- Migration already applied directly to the database. +-- This file exists only to satisfy Prisma's migration directory check (P3015). diff --git a/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql new file mode 100644 index 000000000..34a2e2caa --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql @@ -0,0 +1 @@ +ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index b01ee7b0c..74965df23 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -534,6 +534,7 @@ model Booking { source String @default("WEB") promoCode String? paidAt DateTime? + paymentReminderSentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index a4bce0d14..9e13ad2f9 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -63,6 +63,7 @@ import { SystemConfigModule } from './modules/system-config/system-config.module import { PackagesModule } from './modules/packages/packages.module'; import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; import { HealthModule } from './modules/health/health.module'; +import { TasksModule } from './modules/tasks/tasks.module'; @Module({ imports: [ @@ -131,6 +132,7 @@ import { HealthModule } from './modules/health/health.module'; PackagesModule, ExcessBaggageModule, HealthModule, + TasksModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, diff --git a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts index e50172db9..7810c6d94 100644 --- a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts +++ b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts @@ -47,6 +47,11 @@ export class HttpExceptionFilter implements ExceptionFilter { this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`); } + // When the thrown body is already a structured object (e.g. { status, message, code }), + // merge it into the envelope so callers receive all custom fields. + const customFields = + typeof messageRaw === 'object' && messageRaw !== null ? messageRaw : {}; + response.status(status).json({ success: false, statusCode: status, @@ -54,6 +59,7 @@ export class HttpExceptionFilter implements ExceptionFilter { error: exception instanceof Error ? exception.name : 'Error', timestamp: new Date().toISOString(), path: request.url, + ...customFields, }); } } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 09a1b4ed3..3ebcda273 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -44,7 +44,7 @@ const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = { // Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type function detectBedCategory(coachTypeName: string): BedCategory { const name = coachTypeName.toLowerCase(); - const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette'); + const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette'); if (!isBed) return null; if (name.includes('vip')) return 'VIP_BED'; return 'ECONOMY_BED'; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 8868bb91d..f67e9b3f3 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -51,27 +51,30 @@ export class SeatsService { : 0; const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null; - const mappedSeats = allSeats.map((s: any) => ({ - id: s.id, - seatNumber: s.seatNumber, - label: s.seatNumber, - status: effectiveStatuses.get(s.id) ?? s.status, - kind: s.kind, - row: s.row, - col: s.col, - isWindow: s.isWindow, - isAisle: s.isAisle, - // Bed-specific fields - ...(isBedCoach ? { - room_id: `${a.coach.id}-R${s.row}`, - category: bedCategory, - position: this.colToPosition(s.col), - bed_type: this.bedPositionToType(s.bedPosition), - bedPosition: s.bedPosition, - } : { - bedPosition: s.bedPosition, - }), - })); + const mappedSeats = allSeats.map((s: any) => { + const resolvedBedPosition = isBedCoach + ? this.resolveBedPosition(s.col, s.bedPosition) + : s.bedPosition; + return { + id: s.id, + seatNumber: s.seatNumber, + label: s.seatNumber, + status: effectiveStatuses.get(s.id) ?? s.status, + kind: s.kind, + row: s.row, + col: s.col, + isWindow: s.isWindow, + isAisle: s.isAisle, + bedPosition: resolvedBedPosition, + // Bed-specific fields (only when coach is a bed coach) + ...(isBedCoach ? { + room_id: `${a.coach.id}-R${s.row}`, + category: bedCategory, + position: this.colToPosition(s.col, a.coach.arrangement), + bed_type: this.bedPositionToType(resolvedBedPosition), + } : {}), + }; + }); const base = { id: a.coach.id, @@ -116,7 +119,7 @@ export class SeatsService { private isBedCoach(coachTypeName: string): boolean { const n = coachTypeName.toLowerCase(); - return n.includes('bed') || n.includes('sleeper') || n.includes('couchette'); + return n.includes('bed') || n.includes('berth') || n.includes('sleeper') || n.includes('couchette'); } private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' { @@ -128,9 +131,23 @@ export class SeatsService { return 'ECONOMY_BED'; } - // col format: L1, L2, L3, R1, R2, R3 - private colToPosition(col: string): 'LEFT' | 'RIGHT' { - return col?.startsWith('R') ? 'RIGHT' : 'LEFT'; + // col format: L1, L2, L3, R1, R2, R3 (new) or A, B, C, D (legacy) + // arrangement e.g. "2+2", "3+3", "2+0" → "leftCount+rightCount" + private colToPosition(col: string, arrangement?: string): 'LEFT' | 'RIGHT' | null { + if (!col) return null; + // New named-col format: L1, L2, R1, R2 … + if (/^L\d+$/.test(col)) return 'LEFT'; + if (/^R\d+$/.test(col)) return 'RIGHT'; + // Legacy single-letter cols (A, B, C, D …): derive from arrangement + const colIndex = col.toUpperCase().charCodeAt(0) - 65; // A=0, B=1, C=2 … + if (arrangement) { + const [leftStr, rightStr] = arrangement.split('+'); + const rightCount = parseInt(rightStr ?? '0', 10); + if (rightCount === 0) return 'LEFT'; // single-side berth coach — all LEFT + const leftCount = parseInt(leftStr, 10) || 0; + return colIndex < leftCount ? 'LEFT' : 'RIGHT'; + } + return 'LEFT'; // safe default when no arrangement info } private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null { @@ -141,6 +158,22 @@ export class SeatsService { return map[bedPosition.toLowerCase()] ?? null; } + // Derives bedPosition from col when the seat was created with legacy A/B/C columns + // (new coaches use L1/L2/L3/R1/R2/R3 and store bedPosition explicitly). + // Col-to-tier mapping: A → lower, B → middle, C → upper, D → upper (4-tier). + private resolveBedPosition(col: string, storedBedPosition: string | null): string | null { + if (storedBedPosition) return storedBedPosition; + const legacyMap: Record = { A: 'lower', B: 'middle', C: 'upper', D: 'upper' }; + // Also handle numeric suffix in L/R cols: L1→lower, L2→middle, L3→upper + if (/^[LR]\d+$/.test(col)) { + const tier = parseInt(col.slice(1), 10); + if (tier === 1) return 'lower'; + if (tier === 2) return 'middle'; + return 'upper'; + } + return legacyMap[col?.toUpperCase()] ?? null; + } + async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts new file mode 100644 index 000000000..9759ff297 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../common/prisma.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { TasksService } from './tasks.service'; + +@Module({ + imports: [PrismaModule, NotificationsModule], + providers: [TasksService], +}) +export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts new file mode 100644 index 000000000..70009d1d8 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -0,0 +1,205 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { PrismaService } from '../../common/prisma.service'; +import { SmsClientService } from '../notifications/sms-client.service'; + +/** Minutes before departure at which each action fires. */ +const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS +const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking + +/** Half-width of the reminder detection window (cron runs every 2 min). */ +const REMINDER_WINDOW_MINUTES = 2; + +function fmtTime(d: Date): string { + return d.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'Africa/Addis_Ababa', + }); +} + +@Injectable() +export class TasksService { + private readonly logger = new Logger(TasksService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly sms: SmsClientService, + ) {} + + // ───────────────────────────────────────────────────────────────────────── + // Every 2 min: advance TrainSchedule statuses (departure / arrival). + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/2 * * * *') + async syncScheduleStatuses() { + const now = new Date(); + + const [departed, arrived] = await Promise.all([ + this.prisma.trainSchedule.updateMany({ + where: { status: 'SCHEDULED', departureAt: { lte: now } }, + data: { status: 'EN_ROUTE' }, + }), + this.prisma.trainSchedule.updateMany({ + where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } }, + data: { status: 'ARRIVED' }, + }), + ]); + + if (departed.count > 0 || arrived.count > 0) { + this.logger.log( + `Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`, + ); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Every 2 min: payment deadline enforcement. + // + // • 3 h before departure → send one SMS reminder to complete payment. + // • 2 h before departure → cancel booking if payment is still pending + // and notify the passenger by SMS. + // + // Example: train departs 08:00 + // 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled") + // 06:00 → booking auto-cancelled, cancellation SMS sent + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/2 * * * *') + async enforcePaymentDeadlines() { + const now = new Date(); + + await Promise.all([ + this.sendPaymentReminders(now), + this.cancelExpiredPendingBookings(now), + ]); + } + + // ── 3-hour reminder ─────────────────────────────────────────────────────── + private async sendPaymentReminders(now: Date) { + // Narrow 4-minute window (±2 min around the 3-hour mark) so each booking + // is caught by exactly one cron tick and paymentReminderSentAt guards re-sends. + const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000; + const reminderMs = REMINDER_MINUTES * 60 * 1000; + + const windowStart = new Date(now.getTime() + reminderMs - windowMs); + const windowEnd = new Date(now.getTime() + reminderMs + windowMs); + + const bookings = await this.prisma.booking.findMany({ + where: { + status: 'PENDING_PAYMENT', + paymentReminderSentAt: null, + schedule: { departureAt: { gte: windowStart, lte: windowEnd } }, + } as any, + include: { + schedule: { + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }, + }, + }); + + for (const booking of bookings) { + try { + const dep = booking.schedule.departureAt as Date; + const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000); + const origin = booking.schedule.originStation?.name ?? ''; + const dest = booking.schedule.destinationStation?.name ?? ''; + + const message = + `EDR: Your booking ${booking.bookingRef} ` + + `(${origin} → ${dest}) departs at ${fmtTime(dep)}. ` + + `Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`; + + if (booking.contactPhone) { + await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); + } + + await this.prisma.booking.update({ + where: { id: booking.id }, + data: { paymentReminderSentAt: now } as any, + }); + + this.logger.log( + `Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`, + ); + } catch (err) { + this.logger.error( + `Reminder failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + + // ── 2-hour auto-cancel ──────────────────────────────────────────────────── + private async cancelExpiredPendingBookings(now: Date) { + const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h + + const expiredBookings = await this.prisma.booking.findMany({ + where: { + status: 'PENDING_PAYMENT', + schedule: { departureAt: { lte: cutoff } }, + }, + include: { + schedule: { + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }, + paymentIntent: { select: { method: true } }, + }, + }); + + for (const booking of expiredBookings) { + try { + // 1. Release held seats (Journey rows are the occupancy source of truth) + await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); + + // 2. Audit record (no refund — payment was never completed) + await this.prisma.bookingCancellation.create({ + data: { + bookingId: booking.id, + cancelledBy: 'SYSTEM', + reason: 'Payment not completed before departure deadline', + refundAmount: 0, + refundMethod: booking.paymentIntent?.method ?? 'NONE', + refundStatus: 'NOT_APPLICABLE', + }, + }).catch(() => null); // booking may already have a cancellation record + + // 3. Mark cancelled + await this.prisma.booking.update({ + where: { id: booking.id }, + data: { status: 'CANCELLED' }, + }); + + // 4. Notify passenger + const dep = booking.schedule.departureAt as Date; + const origin = booking.schedule.originStation?.name ?? ''; + const dest = booking.schedule.destinationStation?.name ?? ''; + + const message = + `EDR: Your booking ${booking.bookingRef} ` + + `(${origin} → ${dest}, departs ${fmtTime(dep)}) has been cancelled ` + + `because payment was not completed before the deadline.`; + + if (booking.contactPhone) { + await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`, + ); + } catch (err) { + this.logger.error( + `Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (expiredBookings.length > 0) { + this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`); + } + } +} diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 13e31c91a..2168306d4 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @@ -127,12 +127,37 @@ export class TicketsService { }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + // No payment intent record at all + if (!booking.paymentIntent) { + throw new HttpException( + { status: 'error', message: 'Payment not completed', code: 400 }, + HttpStatus.BAD_REQUEST, + ); + } + + // Payment intent exists but not yet succeeded + if (booking.paymentIntent.status !== 'SUCCEEDED') { + throw new HttpException( + { + status: 'error', + message: 'Payment not completed', + code: 400, + detail: `Payment status: ${booking.paymentIntent.status}`, + }, + HttpStatus.BAD_REQUEST, + ); + } + + // Booking not in CONFIRMED state (safety net — should align with SUCCEEDED) if (booking.status !== 'CONFIRMED') { - const paymentStatus = booking.paymentIntent?.status ?? null; - throw new BadRequestException( - `Payment not completed. Please complete your payment before accessing the ticket. ` + - `Booking status: ${booking.status}` + - (paymentStatus ? `. Payment status: ${paymentStatus}` : ''), + throw new HttpException( + { + status: 'error', + message: 'Payment not completed', + code: 400, + detail: `Booking status: ${booking.status}`, + }, + HttpStatus.BAD_REQUEST, ); } diff --git a/apps/edr-passenger-api/tsconfig.json b/apps/edr-passenger-api/tsconfig.json index 49158fb15..f557f861a 100644 --- a/apps/edr-passenger-api/tsconfig.json +++ b/apps/edr-passenger-api/tsconfig.json @@ -7,9 +7,11 @@ "noEmit": false, "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", - "paths": { "@/*": ["./src/*"] }, - "module": "node16", - "moduleResolution": "node16", + "paths": { + "@/*": ["./src/*"], + "@tria-plc/iamapi-common": ["./node_modules/@tria-plc/iamapi-common/dist/index"], + "@tria-plc/iamapi-common/*": ["./node_modules/@tria-plc/iamapi-common/dist/*"] + }, "strictPropertyInitialization": false, "noUnusedLocals": false, "noUnusedParameters": false diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index fc06005f0..d58b22a98 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -29,6 +29,7 @@ export default function PaymentPage() { usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + const [paymentError, setPaymentError] = useState(null); const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; @@ -60,57 +61,37 @@ export default function PaymentPage() { const paymentMutation = useMutation({ mutationFn: async (data: any) => { - // For all payment methods, use the initiate endpoint - try { - return await apiClient.post("/payments/initiate", { - bookingId: data.bookingId, - method: data.method, - paymentMethodId: data.paymentMethodId, - platform: 'web', - }); - } catch (error) { - console.log("Payment API not available, using mock payment"); - // Mock payment response - return { - paymentIntentId: `mock-payment-${Date.now()}`, - status: "PENDING", - amountMinor: data.amountMinor, - currency: data.currency, - method: data.method, - }; - } + return await apiClient.post("/payments/initiate", { + bookingId: data.bookingId, + method: data.method, + paymentMethodId: data.paymentMethodId, + platform: 'web', + }); }, onSuccess: async (data: any) => { - // Handle TELEBIRR/WAAFI redirect response + setPaymentError(null); + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') { - const redirectUrl = data.clientAction.url; - - // Store the intent ID for later verification setPaymentIntent(data.intentId); updateStatus("REQUIRES_ACTION"); - - // Redirect to payment gateway - window.location.href = redirectUrl; + window.location.href = data.clientAction.url; return; } - + setPaymentIntent(data.paymentIntentId || data.intentId); updateStatus("PROCESSING"); - - // Simulate payment processing await new Promise((resolve) => setTimeout(resolve, 2000)); - updateStatus("SUCCEEDED"); router.push("/booking/confirmation"); }, onError: (error: any) => { console.error("Payment failed:", error); updateStatus("FAILED"); - const errorMessage = + setPaymentError( error?.response?.data?.message || error?.message || - "Payment failed. Please try again."; - alert(errorMessage); + "Payment failed. Please try again.", + ); setIsProcessing(false); }, }); @@ -124,6 +105,7 @@ export default function PaymentPage() { } setIsProcessing(true); + setPaymentError(null); // Find the selected payment method to get its ID const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod); @@ -575,11 +557,10 @@ export default function PaymentPage() { {/* Error Message */} - {paymentMutation.isError && ( + {paymentError && (

- ⚠️ Payment failed. Please try again or contact support if the - problem persists. + ⚠️ {paymentError}

)}