413 lines
14 KiB
TypeScript
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 ''
|
|
}
|
|
}
|