diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index a80c63468..777a2704b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { @@ -18,7 +19,15 @@ const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER]; export class PaymentEventsConsumer { private readonly logger = new Logger(PaymentEventsConsumer.name); - constructor(private readonly paymentsService: PaymentsService) {} + // IMPORTANT: do NOT constructor-inject PaymentsService here. It is a REQUEST/TRANSIENT-scoped + // provider (its scope bubbles up from a scoped dependency), so it has no singleton instance at + // bootstrap. Constructor-injecting it makes THIS consumer scoped too — and golevelup binds the + // @RabbitSubscribe handler to the singleton instance it discovers at bootstrap. With no such + // instance, the subscription still registers but delivered messages are never dispatched to + // handle(): they pile up unacked and the booking never confirms. Injecting only the lightweight + // (singleton) ModuleRef keeps this consumer a clean singleton; PaymentsService is resolved per + // message via resolve() (get() throws for scoped providers). + constructor(private readonly moduleRef: ModuleRef) {} @IsPublic() @RabbitSubscribe({ @@ -37,7 +46,13 @@ export class PaymentEventsConsumer { `RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`, ); try { - const result = await this.paymentsService.handlePaymentEvent( + // resolve() (not get()) because PaymentsService is scoped — get() throws for scoped providers. + const paymentsService = await this.moduleRef.resolve( + PaymentsService, + undefined, + { strict: false }, + ); + const result = await paymentsService.handlePaymentEvent( event as unknown as PaymentEventDto, ); this.logger.log( diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 825fa114e..e0b594fd2 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -154,6 +154,13 @@ export class IntentStatusDto { @ApiPropertyOptional() paidAt?: string; @ApiPropertyOptional() failureCode?: string; @ApiPropertyOptional() failureMessage?: string; + @ApiPropertyOptional({ + type: "object", + additionalProperties: true, + description: + "Raw provider payload (initiation response merged with the latest status query) for inspection/debugging. Provider-specific shape; never trusted for state.", + }) + providerResponse?: Record; } export class BookingAmountResponseDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 78856ccd5..8bf07c6bf 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -432,6 +432,9 @@ export class PaymentsService { expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, failureCode: snapshot.failureCode ?? null, failureMessage: snapshot.failureMessage ?? null, + rawInitiation: snapshot.providerResponse + ? (snapshot.providerResponse as unknown as Prisma.InputJsonValue) + : Prisma.DbNull, }; return this.prisma.paymentIntent.upsert({ where: { bookingId }, @@ -589,6 +592,10 @@ export class PaymentsService { paidAt: intent.paidAt?.toISOString(), failureCode: intent.failureCode ?? undefined, failureMessage: intent.failureMessage ?? undefined, + providerResponse: + intent.rawInitiation && typeof intent.rawInitiation === "object" + ? (intent.rawInitiation as Record) + : undefined, }; } diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx index 9fb0de6bb..45941cea4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx @@ -1,66 +1,30 @@ 'use client'; import { useState } from 'react'; -import { Send, CheckCircle, XCircle, RotateCcw, PlusCircle } from 'lucide-react'; +import { PlusCircle } from 'lucide-react'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; -import Badge from '@/components/ui/Badge'; -import { formatCurrency, formatDateTime } from '@/lib/utils'; -import { - useSupplementaryCharges, - useCreateSupplementaryCharge, - useMarkSupplementaryPaid, - useWaiveSupplementaryCharge, - useResendSupplementaryLink, -} from './useSupplementaryCharges'; +import { useCreateSupplementaryCharge } from './useSupplementaryCharges'; -type Tab = 'create' | 'list'; +const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER']; interface Props { isOpen: boolean; onClose: () => void; } -const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER']; - -const STATUS_COLORS: Record = { - PENDING: 'warning', - PAID: 'success', - WAIVED: 'info', - EXPIRED: 'error', -}; - export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { - const [tab, setTab] = useState('create'); - const [listFilters, setListFilters] = useState({ bookingRef: '', status: '' }); - - // Create form state const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); const [formError, setFormError] = useState(null); const [createSuccess, setCreateSuccess] = useState(null); - const { data: chargesData, isLoading } = useSupplementaryCharges(listFilters); - const charges: any[] = (chargesData as any)?.items ?? (Array.isArray(chargesData) ? chargesData : []); - const createMutation = useCreateSupplementaryCharge(() => { - setCreateSuccess(`Charge created and payment link sent.`); + setCreateSuccess('Charge created and payment link sent.'); setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); setFormError(null); - setTimeout(() => { setCreateSuccess(null); setTab('list'); }, 2000); + setTimeout(() => { setCreateSuccess(null); onClose(); }, 2000); }); - const markPaidMutation = useMarkSupplementaryPaid(); - const waiveMutation = useWaiveSupplementaryCharge(); - const resendMutation = useResendSupplementaryLink(); - - const [actionError, setActionError] = useState(null); - const [actionSuccess, setActionSuccess] = useState(null); - - const flash = (msg: string) => { - setActionSuccess(msg); - setTimeout(() => setActionSuccess(null), 3000); - }; - const handleCreate = async () => { setFormError(null); const amountMinor = Math.round(parseFloat(form.amountEtb) * 100); @@ -73,218 +37,46 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { } }; - const handleMarkPaid = async (id: string) => { - setActionError(null); - try { - await markPaidMutation.mutateAsync({ id }); - flash('Marked as paid'); - } catch (e: any) { - setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); - } - }; - - const handleWaive = async (id: string) => { - setActionError(null); - try { - await waiveMutation.mutateAsync({ id }); - flash('Charge waived'); - } catch (e: any) { - setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); - } - }; - - const handleResend = async (id: string) => { - setActionError(null); - try { - await resendMutation.mutateAsync(id); - flash('Payment link resent'); - } catch (e: any) { - setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); - } - }; - return ( - - {/* Tabs */} -
- {(['create', 'list'] as Tab[]).map((t) => ( - - ))} + +
+ {createSuccess && ( +
✓ {createSuccess}
+ )} + {formError && ( +
{formError}
+ )} + +
+
+ + setForm({ ...form, bookingRef: e.target.value })} /> +
+
+ + setForm({ ...form, amountEtb: e.target.value })} /> +
+
+ + +
+
+ +