325 lines
11 KiB
TypeScript
Executable file
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',
|
|
})
|
|
}
|
|
}
|
|
}
|