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

605 lines
22 KiB
TypeScript
Executable file

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 { SegpayProvider } from '@/segpay/segpay.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'
export interface SegpayWebhookPayload {
event: string
data: Record<string, unknown>
timestamp: number
[key: string]: unknown
}
/**
* Segpay Webhook Controller
*
* Handles incoming webhooks from Segpay payment gateway.
* Validates signatures, prevents replay attacks, and processes events.
*/
@Controller('webhooks/segpay')
export class SegpayWebhookController {
private readonly logger = new Logger(SegpayWebhookController.name)
private readonly TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000 // 5 minutes
constructor(
private readonly segpayProvider: SegpayProvider,
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-segpay-signature') signature: string,
@Body() payload: SegpayWebhookPayload,
) {
// Validate signature header
if (!signature) {
this.logger.warn('Missing X-Segpay-Signature header')
throw new UnauthorizedException('Missing signature')
}
const rawBody = req.rawBody?.toString() || JSON.stringify(payload)
// Verify webhook signature
const webhookSecret = this.configService.get<string>('SEGPAY_WEBHOOK_SECRET', '')
if (!webhookSecret) {
this.logger.error('SEGPAY_WEBHOOK_SECRET is not configured')
throw new BadRequestException('Webhook verification unavailable')
}
const isValid = await this.segpayProvider.verifyWebhookSignature({
rawBody,
signature,
secret: webhookSecret,
})
if (!isValid) {
this.logger.warn('Invalid Segpay webhook signature')
throw new UnauthorizedException('Invalid signature')
}
// Prevent replay attacks
const now = Date.now()
if (Math.abs(now - payload.timestamp) > this.TIMESTAMP_TOLERANCE_MS) {
this.logger.warn(`Segpay webhook timestamp too old: ${payload.timestamp}`)
throw new BadRequestException('Webhook timestamp too old')
}
// Store webhook event with idempotency check (database-backed)
const idempotencyKey = this.extractExternalId(payload)
const { event, isDuplicate } = await this.webhookEvents.storeWebhookEvent({
provider: 'segpay',
eventType: payload.event,
payload,
idempotencyKey,
})
if (isDuplicate) {
this.logger.log(`Duplicate webhook 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 webhook: ${event.id}`)
} catch (error) {
await this.webhookEvents.markAsFailed(event.id, error as Error)
this.logger.error(`Failed to process webhook ${event.id}: ${(error as Error).message}`)
// Don't throw - return success to prevent provider retries for non-transient errors
// Failed events can be inspected and retried manually via admin interface
}
return { received: true, eventId: event.id }
}
private extractExternalId(payload: SegpayWebhookPayload): string {
const { data } = payload
return (
(data.subscriptionId as string) ||
(data.transactionId as string) ||
(data.chargebackId as string) ||
(data.giftCardId as string) ||
`${payload.event}_${payload.timestamp}`
)
}
private async processEvent(payload: SegpayWebhookPayload): Promise<void> {
this.logger.log(`Processing Segpay event: ${payload.event}`)
switch (payload.event) {
case 'subscription.created':
await this.handleSubscriptionCreated(payload.data)
break
case 'subscription.cancelled':
await this.handleSubscriptionCancelled(payload.data)
break
case 'subscription.renewed':
await this.handleSubscriptionRenewed(payload.data)
break
case 'payment.succeeded':
await this.handlePaymentSucceeded(payload.data)
break
case 'payment.failed':
await this.handlePaymentFailed(payload.data)
break
case 'gift_card.purchased':
await this.handleGiftCardPurchased(payload.data)
break
case 'chargeback.created':
await this.handleChargebackCreated(payload.data)
break
case 'chargeback.won':
await this.handleChargebackWon(payload.data)
break
case 'chargeback.lost':
await this.handleChargebackLost(payload.data)
break
default:
this.logger.warn(`Unknown Segpay event type: ${payload.event}`)
}
}
private async handleSubscriptionCreated(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Subscription created: ${data.subscriptionId}, user: ${data.userId}`)
const userId = data.userId as string
const amount = Number(data.amount || 0)
const subscriptionId = (data.subscriptionId as string) || `sub_${Date.now()}`
const transactionId = (data.transactionId as string) || subscriptionId
const creatorId = data.creatorId as string
// Record creator earnings (if creator is known)
if (creatorId && amount) {
const grossCents = Math.round(amount * 100)
await this.earningsService
.recordEarning({
transactionId,
creatorUserId: creatorId,
grossCents,
type: EarningsType.SUBSCRIPTION,
payerUserId: userId,
metadata: { subscriptionId, event: 'subscription.created' },
})
.catch((err: Error) => this.logger.error(`Failed to record earnings for subscription.created: ${err.message}`))
}
// Track revenue for analytics
if (userId && amount) {
await this.paymentAnalytics.trackRevenue({
userId,
transactionId,
transactionType: TransactionType.SUBSCRIPTION,
amount,
currency: (data.currency as string) || 'USD',
platformFee: Number(data.platformFee || 0),
metadata: 'subscription.created',
})
// Emit SUBSCRIPTION_CREATED domain event
this.domainEvents
.emitSubscriptionCreated({
subscriptionId,
userId,
tierId: (data.tierId as string) || 'default',
transactionId,
amountInCents: Math.round(amount * 100),
currency: (data.currency as string) || 'USD',
interval: (data.interval as 'monthly' | 'yearly' | 'weekly' | 'daily') || 'monthly',
createdAt: new Date().toISOString(),
merchantId: data.merchantId as string,
metadata: {
provider: 'segpay',
platformFee: Number(data.platformFee || 0),
},
})
.catch((err: Error) => this.logger.warn(`Failed to emit subscription created event: ${err.message}`))
// Emit PURCHASE event for conversion funnel (first subscription payment)
// Uses transactionId as correlationId since we don't have analytics session
this.domainEvents
.emitPurchase({
sessionId: transactionId, // Use transactionId as correlation key
userId,
transactionId,
amountInCents: Math.round(amount * 100),
type: 'subscription',
attribution: this.domainEvents.createEmptyAttribution(),
})
.catch((err: Error) => this.logger.warn(`Failed to emit purchase event: ${err.message}`))
}
}
private async handleSubscriptionCancelled(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Subscription cancelled: ${data.subscriptionId}`)
const subscriptionId = data.subscriptionId as string
const userId = data.userId as string
if (subscriptionId && userId) {
// Emit SUBSCRIPTION_CANCELLED domain event
this.domainEvents
.emitSubscriptionCancelled({
subscriptionId,
userId,
tierId: (data.tierId as string) || 'default',
reason: (['customer_request', 'payment_failed', 'fraud', 'merchant_disabled', 'other'] as const).includes(data.reason as 'customer_request') ? (data.reason as 'customer_request') : 'customer_request',
cancelledAt: new Date().toISOString(),
endsAt: (data.endsAt as string) || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
merchantId: data.merchantId as string,
metadata: {
provider: 'segpay',
},
})
.catch((err: Error) => this.logger.warn(`Failed to emit subscription cancelled event: ${err.message}`))
}
}
private async handleSubscriptionRenewed(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Subscription renewed: ${data.subscriptionId}`)
const userId = data.userId as string
const amount = Number(data.amount || 0)
const subscriptionId = (data.subscriptionId as string) || `sub_${Date.now()}`
const transactionId = (data.transactionId as string) || `renewal_${Date.now()}`
const renewalCount = Number(data.renewalCount || 1)
const creatorId = data.creatorId as string
// Record creator earnings on renewal
if (creatorId && amount) {
const grossCents = Math.round(amount * 100)
await this.earningsService
.recordEarning({
transactionId,
creatorUserId: creatorId,
grossCents,
type: EarningsType.SUBSCRIPTION,
payerUserId: userId,
metadata: { subscriptionId, renewalCount, event: 'subscription.renewed' },
})
.catch((err: Error) => this.logger.error(`Failed to record earnings for subscription.renewed: ${err.message}`))
}
// Track renewal revenue
if (userId && amount) {
await this.paymentAnalytics.trackRevenue({
userId,
transactionId,
transactionType: TransactionType.SUBSCRIPTION,
amount,
currency: (data.currency as string) || 'USD',
platformFee: Number(data.platformFee || 0),
metadata: 'subscription.renewed',
})
// Emit SUBSCRIPTION_RENEWED domain event
this.domainEvents
.emitSubscriptionRenewed({
subscriptionId,
userId,
tierId: (data.tierId as string) || 'default',
transactionId,
amountInCents: Math.round(amount * 100),
currency: (data.currency as string) || 'USD',
interval: (data.interval as 'monthly' | 'yearly' | 'weekly' | 'daily') || 'monthly',
renewalCount,
renewedAt: new Date().toISOString(),
merchantId: data.merchantId as string,
metadata: {
provider: 'segpay',
platformFee: Number(data.platformFee || 0),
},
})
.catch((err: Error) => this.logger.warn(`Failed to emit subscription renewed event: ${err.message}`))
// Emit REPEAT_PURCHASE event for conversion funnel (subscription renewals are repeat)
const purchaseCount = renewalCount + 1 // renewalCount=1 means 2nd payment
this.domainEvents
.emitRepeatPurchase({
sessionId: transactionId,
userId,
transactionId,
amountInCents: Math.round(amount * 100),
purchaseCount,
attribution: this.domainEvents.createEmptyAttribution(),
})
.catch((err: Error) => this.logger.warn(`Failed to emit repeat purchase event: ${err.message}`))
}
}
private async handlePaymentSucceeded(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Payment succeeded: ${data.transactionId}, amount: $${data.amount}`)
const userId = data.userId as string
const amount = Number(data.amount || 0)
const transactionId = data.transactionId as string
const paymentType = data.paymentType as string
const creatorId = data.creatorId as string
// Record creator earnings for non-subscription payments (subscriptions handled separately)
if (creatorId && amount && paymentType !== 'subscription') {
const grossCents = Math.round(amount * 100)
const earningsType =
paymentType === 'tip' ? EarningsType.TIP :
paymentType === 'booking' ? EarningsType.BOOKING :
paymentType === 'gift_card' ? EarningsType.GIFT_CARD :
EarningsType.TIP // Default to TIP for unknown one-time payment types
await this.earningsService
.recordEarning({
transactionId,
creatorUserId: creatorId,
grossCents,
type: earningsType,
payerUserId: userId,
metadata: { paymentType, event: 'payment.succeeded' },
})
.catch((err: Error) => this.logger.error(`Failed to record earnings for payment.succeeded: ${err.message}`))
}
// Determine transaction type from payment type
let transactionType = TransactionType.PRODUCTSALE
if (paymentType === 'tip') {
transactionType = TransactionType.TIP
} else if (paymentType === 'booking') {
transactionType = TransactionType.SERVICEBOOKING
}
// Track revenue
if (userId && amount && transactionId) {
await this.paymentAnalytics.trackRevenue({
userId,
transactionId,
transactionType,
amount,
currency: (data.currency as string) || 'USD',
platformFee: Number(data.platformFee || 0),
metadata: `payment.succeeded.${paymentType || 'product'}`,
})
// Map payment type classification
const paymentTypeClassified: 'one_time' | 'tip' | 'booking' | 'gift_card' =
paymentType === 'tip' ? 'tip' :
paymentType === 'booking' ? 'booking' :
paymentType === 'gift_card' ? 'gift_card' : 'one_time'
// Determine payment method (card is most common, anonymize details)
const paymentMethod: 'card' | 'crypto' | 'bank_transfer' | 'other' = 'card'
// Emit PAYMENT_COMPLETED domain event
this.domainEvents
.emitPaymentCompleted({
transactionId,
userId,
amountInCents: Math.round(amount * 100),
currency: (data.currency as string) || 'USD',
paymentMethod,
paymentType: paymentTypeClassified,
completedAt: new Date().toISOString(),
merchantId: data.merchantId as string,
productId: data.productId as string,
metadata: {
provider: 'segpay',
platformFee: Number(data.platformFee || 0),
},
})
.catch((err: Error) => this.logger.warn(`Failed to emit payment completed event: ${err.message}`))
// Map payment type to funnel purchase type
const purchaseType: 'subscription' | 'one_time' | 'tip' =
paymentType === 'tip' ? 'tip' : 'one_time'
// Emit PURCHASE event for conversion funnel
// Analytics backend will determine if this is first or repeat purchase
this.domainEvents
.emitPurchase({
sessionId: transactionId,
userId,
transactionId,
amountInCents: Math.round(amount * 100),
type: purchaseType,
attribution: this.domainEvents.createEmptyAttribution(),
})
.catch((err: Error) => this.logger.warn(`Failed to emit purchase event: ${err.message}`))
}
}
private async handlePaymentFailed(data: Record<string, unknown>): Promise<void> {
const transactionId = data.transactionId as string
const failureReason = (data.failureReason as string) || 'unknown'
const userId = data.userId as string
this.logger.warn(`Payment failed: ${transactionId}, reason: ${failureReason}`)
// Update transaction status in ledger if it exists
if (transactionId) {
const transaction = await this.transactionRepository.findOne({
where: { providerTransactionId: transactionId },
})
if (transaction) {
transaction.status = TransactionStatus.FAILED
transaction.metadata = {
...transaction.metadata,
failureReason,
failedAt: new Date().toISOString(),
}
await this.transactionRepository.save(transaction)
}
}
// Emit PAYMENT_FAILED domain event for user notification
if (userId && transactionId) {
this.domainEvents
.emit(
'payment:failed',
{
transactionId,
userId,
amountInCents: Math.round(Number(data.amount || 0) * 100),
currency: (data.currency as string) || 'USD',
failureReason,
failedAt: new Date().toISOString(),
merchantId: (data.merchantId as string) || '',
metadata: { provider: 'segpay' },
},
transactionId,
`payment_failed:${transactionId}`,
)
.catch((err: Error) => this.logger.warn(`Failed to emit payment failed event: ${err.message}`))
}
}
private async handleGiftCardPurchased(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Gift card purchased: ${data.giftCardId}, amount: $${data.amount}`)
// Track gift card as product sale
if (data.purchaserId && data.amount) {
await this.paymentAnalytics.trackRevenue({
userId: data.purchaserId as string,
transactionId: (data.giftCardId as string) || `gc_${Date.now()}`,
transactionType: TransactionType.PRODUCTSALE,
amount: Number(data.amount),
currency: (data.currency as string) || 'USD',
platformFee: Number(data.platformFee || 0),
metadata: 'gift_card.purchased',
})
}
}
private async handleChargebackCreated(data: Record<string, unknown>): Promise<void> {
const chargebackId = (data.chargebackId as string) || `cb_${Date.now()}`
const transactionId = data.transactionId as string
const amount = Number(data.amount || 0)
const userId = data.userId as string
this.logger.warn(`Chargeback created: ${chargebackId}, transaction: ${transactionId}, amount: $${amount}`)
// Flag the original transaction as disputed
if (transactionId) {
const transaction = await this.transactionRepository.findOne({
where: { providerTransactionId: transactionId },
})
if (transaction) {
transaction.metadata = {
...transaction.metadata,
chargebackId,
chargebackStatus: 'open',
chargebackCreatedAt: new Date().toISOString(),
}
await this.transactionRepository.save(transaction)
}
}
// Emit domain event for admin notification
this.domainEvents
.emitPaymentRefunded({
transactionId: transactionId || chargebackId,
refundId: chargebackId,
userId: userId || '',
amountInCents: Math.round(amount * 100),
currency: (data.currency as string) || 'USD',
reason: 'chargeback',
refundType: 'full',
refundedAt: new Date().toISOString(),
merchantId: (data.merchantId as string) || '',
metadata: {
provider: 'segpay',
chargebackId,
chargebackStatus: 'open',
},
})
.catch((err: Error) => this.logger.warn(`Failed to emit chargeback created event: ${err.message}`))
}
private async handleChargebackWon(data: Record<string, unknown>): Promise<void> {
const chargebackId = (data.chargebackId as string) || ''
const transactionId = data.transactionId as string
this.logger.log(`Chargeback won: ${chargebackId}`)
// Update transaction metadata to reflect chargeback resolution
if (transactionId) {
const transaction = await this.transactionRepository.findOne({
where: { providerTransactionId: transactionId },
})
if (transaction) {
transaction.metadata = {
...transaction.metadata,
chargebackStatus: 'won',
chargebackResolvedAt: new Date().toISOString(),
}
await this.transactionRepository.save(transaction)
}
}
}
private async handleChargebackLost(data: Record<string, unknown>): Promise<void> {
this.logger.warn(`Chargeback lost: ${data.chargebackId}`)
// Track refund (negative revenue)
if (data.userId && data.amount) {
const userId = data.userId as string
const transactionId = (data.originalTransactionId as string) || (data.transactionId as string) || ''
const refundId = (data.chargebackId as string) || `chargeback_${Date.now()}`
const amount = Number(data.amount)
await this.paymentAnalytics.trackRefund({
userId,
transactionId: refundId,
amount,
currency: (data.currency as string) || 'USD',
platformFee: Number(data.platformFee || 0),
metadata: 'chargeback.lost',
})
// Emit PAYMENT_REFUNDED domain event
this.domainEvents
.emitPaymentRefunded({
transactionId,
refundId,
userId,
amountInCents: Math.round(amount * 100),
currency: (data.currency as string) || 'USD',
reason: 'chargeback',
refundType: 'full',
refundedAt: new Date().toISOString(),
merchantId: data.merchantId as string,
metadata: {
provider: 'segpay',
chargebackId: refundId,
},
})
.catch((err: Error) => this.logger.warn(`Failed to emit payment refunded event: ${err.message}`))
}
}
}