platform-codebase/features/payments/backend-api/webhooks/nowpayments.webhook.controller.ts

413 lines
14 KiB
TypeScript

import { DomainEventsEmitter } from '@lilith/domain-events'
import {
Controller,
Post,
Body,
Headers,
HttpCode,
UnauthorizedException,
BadRequestException,
Logger,
Req,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import type { RawBodyRequest } from '@nestjs/common'
import type { Request } from 'express'
import { NOWPaymentsProvider } from '@/nowpayments/nowpayments.provider'
import { PaymentAnalyticsService, TransactionType } from '@/services/payment-analytics.service'
import { WebhookEventsService } from '@/services/webhook-events.service'
import { EarningsService } from '@/earnings/earnings.service'
import { EarningsType } from '@/src/entities/earnings-entry.entity'
import { TransactionEntity } from '@/src/entities/transaction.entity'
import { TransactionStatus } from '@/providers/transaction.types'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
/**
* NOWPayments IPN payload format
*
* See: https://documenter.getpostman.com/view/7907941/S1a32n38#ipn-callback
*/
export interface NOWPaymentsIPNPayload {
payment_id: number
payment_status: string
pay_address: string
price_amount: number
price_currency: string
pay_amount: number
pay_currency: string
order_id: string
order_description: string
purchase_id?: string
actually_paid?: number
actually_paid_at_fiat?: number
outcome_amount?: number
outcome_currency?: string
[key: string]: unknown
}
/**
* NOWPayments Webhook Controller
*
* Handles incoming IPN (Instant Payment Notification) callbacks from NOWPayments.
* Validates HMAC-SHA512 signatures, prevents duplicate processing, and routes events.
*/
@Controller('webhooks/nowpayments')
export class NOWPaymentsWebhookController {
private readonly logger = new Logger(NOWPaymentsWebhookController.name)
constructor(
private readonly nowPaymentsProvider: NOWPaymentsProvider,
private readonly paymentAnalytics: PaymentAnalyticsService,
private readonly domainEvents: DomainEventsEmitter,
private readonly webhookEvents: WebhookEventsService,
private readonly earningsService: EarningsService,
private readonly configService: ConfigService,
@InjectRepository(TransactionEntity)
private readonly transactionRepository: Repository<TransactionEntity>,
) {}
@Post()
@HttpCode(200)
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('x-nowpayments-sig') signature: string,
@Body() payload: NOWPaymentsIPNPayload,
) {
// Validate signature header
if (!signature) {
this.logger.warn('Missing x-nowpayments-sig header')
throw new UnauthorizedException('Missing signature')
}
const rawBody = req.rawBody?.toString() || JSON.stringify(payload)
// Verify HMAC-SHA512 signature (NOWPayments uses sorted JSON payload)
const ipnSecret = this.configService.get<string>('NOWPAYMENTS_IPN_SECRET', '')
if (!ipnSecret) {
this.logger.error('NOWPAYMENTS_IPN_SECRET is not configured')
throw new BadRequestException('Webhook verification unavailable')
}
const isValid = await this.nowPaymentsProvider.verifyWebhookSignature({
rawBody,
signature,
secret: ipnSecret,
})
if (!isValid) {
this.logger.warn('Invalid NOWPayments IPN signature')
throw new UnauthorizedException('Invalid signature')
}
// Store webhook event with idempotency check (database-backed)
const idempotencyKey = `np_${payload.payment_id}`
const eventType = this.mapPaymentStatusToEventType(payload.payment_status)
const { event, isDuplicate } = await this.webhookEvents.storeWebhookEvent({
provider: 'nowpayments',
eventType,
payload: payload as unknown as Record<string, unknown>,
idempotencyKey,
})
if (isDuplicate) {
this.logger.log(`Duplicate IPN event: ${idempotencyKey} (${event.id})`)
return { received: true, duplicate: true, eventId: event.id }
}
// Process the event with error handling
try {
await this.processEvent(payload)
await this.webhookEvents.markAsProcessed(event.id)
this.logger.log(`Successfully processed IPN: ${event.id}`)
} catch (error) {
await this.webhookEvents.markAsFailed(event.id, error as Error)
this.logger.error(`Failed to process IPN ${event.id}: ${(error as Error).message}`)
// Don't throw - return success to prevent NOWPayments retries for non-transient errors
}
return { received: true, eventId: event.id }
}
private mapPaymentStatusToEventType(paymentStatus: string): string {
const eventMap: Record<string, string> = {
finished: 'payment.succeeded',
confirmed: 'payment.succeeded',
failed: 'payment.failed',
expired: 'payment.expired',
partially_paid: 'payment.partial',
refunded: 'payment.refunded',
}
return eventMap[paymentStatus] || 'payment.updated'
}
private async processEvent(payload: NOWPaymentsIPNPayload): Promise<void> {
const { payment_status } = payload
this.logger.log(`Processing NOWPayments IPN: payment_id=${payload.payment_id}, status=${payment_status}`)
switch (payment_status) {
case 'finished':
case 'confirmed':
await this.handlePaymentSucceeded(payload)
break
case 'failed':
await this.handlePaymentFailed(payload)
break
case 'expired':
await this.handlePaymentExpired(payload)
break
case 'partially_paid':
this.logger.warn(
`Partial payment received: payment_id=${payload.payment_id}, ` +
`paid=${payload.actually_paid} ${payload.pay_currency}, ` +
`expected=${payload.pay_amount} ${payload.pay_currency}`,
)
break
case 'refunded':
await this.handlePaymentRefunded(payload)
break
default:
this.logger.warn(`Unknown NOWPayments payment status: ${payment_status}`)
}
}
private async handlePaymentSucceeded(payload: NOWPaymentsIPNPayload): Promise<void> {
const orderId = payload.order_id
const priceAmount = Number(payload.price_amount || 0)
const paymentId = String(payload.payment_id)
this.logger.log(
`Crypto payment succeeded: payment_id=${paymentId}, ` +
`amount=$${priceAmount} ${payload.price_currency}, ` +
`paid=${payload.pay_amount} ${payload.pay_currency}`,
)
// Update transaction status in ledger
if (paymentId) {
const transaction = await this.transactionRepository.findOne({
where: { providerTransactionId: paymentId },
})
if (transaction) {
transaction.status = TransactionStatus.COMPLETED
transaction.metadata = {
...transaction.metadata,
paymentStatus: payload.payment_status,
payCurrency: payload.pay_currency,
payAmount: payload.pay_amount,
actuallyPaid: payload.actually_paid,
completedAt: new Date().toISOString(),
}
await this.transactionRepository.save(transaction)
}
}
// Extract userId and creatorId from order_id or metadata
// Order ID format: sub_<userId>_<tierId>_<timestamp> or tx_<userId>_<type>_<timestamp>
const userId = this.extractUserIdFromOrderId(orderId)
const creatorId = payload.creatorId as string | undefined
// Record creator earnings
if (creatorId && priceAmount) {
const grossCents = Math.round(priceAmount * 100)
await this.earningsService
.recordEarning({
transactionId: paymentId,
creatorUserId: creatorId,
grossCents,
type: EarningsType.TIP, // Default for crypto one-time payments
payerUserId: userId,
metadata: { paymentStatus: payload.payment_status, payCurrency: payload.pay_currency },
})
.catch((err: Error) => this.logger.error(`Failed to record earnings: ${err.message}`))
}
// Track revenue for analytics
if (userId && priceAmount) {
await this.paymentAnalytics.trackRevenue({
userId,
transactionId: paymentId,
transactionType: TransactionType.PRODUCTSALE,
amount: priceAmount,
currency: (payload.price_currency as string) || 'USD',
platformFee: 0,
metadata: `nowpayments.${payload.payment_status}`,
})
// Emit PAYMENT_COMPLETED domain event
this.domainEvents
.emitPaymentCompleted({
transactionId: paymentId,
userId,
amountInCents: Math.round(priceAmount * 100),
currency: (payload.price_currency as string) || 'USD',
paymentMethod: 'crypto',
paymentType: 'one_time',
completedAt: new Date().toISOString(),
merchantId: '',
productId: orderId,
metadata: {
provider: 'nowpayments',
payCurrency: payload.pay_currency,
payAmount: payload.pay_amount,
actuallyPaid: payload.actually_paid,
},
})
.catch((err: Error) => this.logger.warn(`Failed to emit payment completed event: ${err.message}`))
// Emit PURCHASE event for conversion funnel
this.domainEvents
.emitPurchase({
sessionId: paymentId,
userId,
transactionId: paymentId,
amountInCents: Math.round(priceAmount * 100),
type: 'one_time',
attribution: this.domainEvents.createEmptyAttribution(),
})
.catch((err: Error) => this.logger.warn(`Failed to emit purchase event: ${err.message}`))
}
}
private async handlePaymentFailed(payload: NOWPaymentsIPNPayload): Promise<void> {
const paymentId = String(payload.payment_id)
this.logger.warn(`Crypto payment failed: payment_id=${paymentId}, order_id=${payload.order_id}`)
// Update transaction status in ledger
if (paymentId) {
const transaction = await this.transactionRepository.findOne({
where: { providerTransactionId: paymentId },
})
if (transaction) {
transaction.status = TransactionStatus.FAILED
transaction.metadata = {
...transaction.metadata,
paymentStatus: 'failed',
failedAt: new Date().toISOString(),
}
await this.transactionRepository.save(transaction)
}
}
// Emit payment failed domain event
const userId = this.extractUserIdFromOrderId(payload.order_id)
if (userId && paymentId) {
this.domainEvents
.emit(
'payment:failed',
{
transactionId: paymentId,
userId,
amountInCents: Math.round(Number(payload.price_amount || 0) * 100),
currency: (payload.price_currency as string) || 'USD',
failureReason: 'crypto_payment_failed',
failedAt: new Date().toISOString(),
merchantId: '',
metadata: { provider: 'nowpayments' },
},
paymentId,
`payment_failed:${paymentId}`,
)
.catch((err: Error) => this.logger.warn(`Failed to emit payment failed event: ${err.message}`))
}
}
private async handlePaymentExpired(payload: NOWPaymentsIPNPayload): Promise<void> {
const paymentId = String(payload.payment_id)
this.logger.warn(`Crypto payment expired: payment_id=${paymentId}, order_id=${payload.order_id}`)
// Update transaction status in ledger
if (paymentId) {
const transaction = await this.transactionRepository.findOne({
where: { providerTransactionId: paymentId },
})
if (transaction) {
transaction.status = TransactionStatus.CANCELLED
transaction.metadata = {
...transaction.metadata,
paymentStatus: 'expired',
expiredAt: new Date().toISOString(),
}
await this.transactionRepository.save(transaction)
}
}
}
private async handlePaymentRefunded(payload: NOWPaymentsIPNPayload): Promise<void> {
const paymentId = String(payload.payment_id)
const priceAmount = Number(payload.price_amount || 0)
const userId = this.extractUserIdFromOrderId(payload.order_id)
this.logger.warn(`Crypto payment refunded: payment_id=${paymentId}, amount=$${priceAmount}`)
// Update transaction status
if (paymentId) {
const transaction = await this.transactionRepository.findOne({
where: { providerTransactionId: paymentId },
})
if (transaction) {
transaction.status = TransactionStatus.REFUNDED
transaction.metadata = {
...transaction.metadata,
paymentStatus: 'refunded',
refundedAt: new Date().toISOString(),
}
await this.transactionRepository.save(transaction)
}
}
// Track refund analytics
if (userId && priceAmount) {
await this.paymentAnalytics.trackRefund({
userId,
transactionId: paymentId,
amount: priceAmount,
currency: (payload.price_currency as string) || 'USD',
platformFee: 0,
metadata: 'nowpayments.refunded',
})
}
// Emit refund domain event
if (userId) {
this.domainEvents
.emitPaymentRefunded({
transactionId: paymentId,
refundId: `np_refund_${paymentId}`,
userId,
amountInCents: Math.round(priceAmount * 100),
currency: (payload.price_currency as string) || 'USD',
reason: 'other',
refundType: 'full',
refundedAt: new Date().toISOString(),
merchantId: '',
metadata: {
provider: 'nowpayments',
payCurrency: payload.pay_currency,
},
})
.catch((err: Error) => this.logger.warn(`Failed to emit payment refunded event: ${err.message}`))
}
}
/**
* Extract userId from NOWPayments order_id
*
* Order IDs follow the format: sub_<userId>_<tierId>_<timestamp>
* or tx_<userId>_<type>_<timestamp>
*/
private extractUserIdFromOrderId(orderId: string): string {
if (!orderId) return ''
const parts = orderId.split('_')
// Format: prefix_userId_rest...
if (parts.length >= 3) {
return parts[1]
}
return ''
}
}