605 lines
22 KiB
TypeScript
Executable file
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}`))
|
|
}
|
|
}
|
|
}
|