import { Injectable, Logger } from '@nestjs/common'; import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; import { CardProvider, CardWebhookPayload, ProviderPaymentStatus, } from '@edr/payment-providers'; import { PrismaService } from '../../../common/prisma.service'; import { PaymentsService } from '../payments.service'; @Injectable() export class CardWebhookService { private readonly logger = new Logger(CardWebhookService.name); constructor( private readonly prisma: PrismaService, private readonly provider: CardProvider, private readonly payments: PaymentsService, ) {} async handle(payload: CardWebhookPayload, signature: string): Promise { const merchantOrderId = payload.data.object.metadata.merchantOrderId; const externalEventId = `${payload.id}_${payload.type}`; const signatureValid = this.provider.verifyWebhookSignature( payload as unknown as Record, signature, ); const eventRow = await this.persistEvent({ externalEventId, merchantOrderId, providerTxnId: payload.data.object.transaction_id, signatureValid, status: payload.data.object.status, payload, }); if (!eventRow) { this.logger.log(`Card webhook duplicate: ${externalEventId} — short-circuit OK`); return; } if (!signatureValid) { this.logger.warn(`Card webhook signature invalid for merchantOrderId=${merchantOrderId}`); await this.markProcessed(eventRow.id, 'signature-invalid'); return; } const intent = await this.prisma.paymentIntent.findUnique({ where: { merchantOrderId }, }); if (!intent) { this.logger.warn(`Card webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`); await this.markProcessed(eventRow.id, 'intent-not-found'); return; } const mapped = this.provider.mapWebhookStatus(payload.data.object.status); try { if (mapped === ProviderPaymentStatus.SUCCEEDED) { await this.payments.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: payload.data.object.transaction_id, paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined, }); } else if (mapped === ProviderPaymentStatus.FAILED) { await this.payments.markPaymentFailed({ intentId: intent.id, failureCode: payload.data.object.failure_code, failureMessage: payload.data.object.failure_message, }); } else { await this.prisma.paymentIntent.update({ where: { id: intent.id }, data: { status: mapped as unknown as PaymentIntentStatus, providerTxnId: payload.data.object.transaction_id ?? undefined, }, }); } await this.markProcessed(eventRow.id); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.error(`Card webhook processing failed for ${merchantOrderId}: ${message}`); await this.markProcessed(eventRow.id, `processing-error: ${message}`); throw err; } } private async persistEvent(input: { externalEventId: string; merchantOrderId: string; providerTxnId?: string; signatureValid: boolean; status: string; payload: CardWebhookPayload; }): Promise<{ id: string } | null> { try { return await this.prisma.paymentWebhookEvent.create({ data: { provider: PaymentMethodType.CARD, externalEventId: input.externalEventId, merchantOrderId: input.merchantOrderId, providerTxnId: input.providerTxnId, signatureValid: input.signatureValid, status: input.status, payload: input.payload as unknown as Prisma.InputJsonValue, }, select: { id: true }, }); } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002' ) { return null; } throw err; } } private async markProcessed(eventId: string, processingError?: string): Promise { await this.prisma.paymentWebhookEvent.update({ where: { id: eventId }, data: { processedAt: new Date(), processingError }, }); } }