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

325 lines
11 KiB
TypeScript
Executable file

import { DomainEventsEmitter } from '@lilith/domain-events'
import {
Controller,
Post,
Body,
Headers,
HttpCode,
UnauthorizedException,
BadRequestException,
Logger,
Req,
} from '@nestjs/common'
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'
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,
) {}
@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 isValid = await this.segpayProvider.verifyWebhookSignature({
rawBody,
signature,
secret: '',
})
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 transactionId = (data.subscriptionId as string) || `sub_${Date.now()}`
// 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 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}`)
// TODO: Update subscription status in database
}
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 transactionId = (data.transactionId as string) || `renewal_${Date.now()}`
// 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 REPEAT_PURCHASE event for conversion funnel (subscription renewals are repeat)
const purchaseCount = Number(data.renewalCount || 2) // Renewals are at least 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
// 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 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> {
this.logger.log(`Payment failed: ${data.transactionId}, reason: ${data.failureReason}`)
// TODO: Update transaction status, notify user
}
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> {
this.logger.warn(
`Chargeback created: ${data.chargebackId}, transaction: ${data.transactionId}, amount: $${data.amount}`,
)
// TODO: Flag transaction, notify admin
}
private async handleChargebackWon(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Chargeback won: ${data.chargebackId}`)
// TODO: Update chargeback status
}
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) {
await this.paymentAnalytics.trackRefund({
userId: data.userId as string,
transactionId: (data.chargebackId as string) || `chargeback_${Date.now()}`,
amount: Number(data.amount),
currency: (data.currency as string) || 'USD',
platformFee: Number(data.platformFee || 0),
metadata: 'chargeback.lost',
})
}
}
}