133 lines
3.7 KiB
TypeScript
133 lines
3.7 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Param,
|
|
Body,
|
|
Query,
|
|
Logger,
|
|
NotFoundException,
|
|
BadRequestException,
|
|
} from '@nestjs/common'
|
|
import { InjectRepository } from '@nestjs/typeorm'
|
|
import { Repository } from 'typeorm'
|
|
|
|
import { TransactionEntity } from '@/src/entities/transaction.entity'
|
|
import { PaymentWebhookEvent } from '@/src/entities/payment-webhook-event.entity'
|
|
import { TransactionStatus } from '@/providers/transaction.types'
|
|
|
|
/**
|
|
* Admin Transactions Controller
|
|
*
|
|
* Administrative endpoints for managing transactions and viewing audit logs.
|
|
* Matches frontend adminTransactionsApi contract.
|
|
*
|
|
* Routes:
|
|
* - GET /admin/transactions — list all transactions (with optional subscriptionId filter)
|
|
* - POST /admin/transactions/:id/refund — issue refund
|
|
* - GET /admin/transactions/audit-logs — webhook event audit trail
|
|
*/
|
|
@Controller('admin/transactions')
|
|
export class AdminTransactionsController {
|
|
private readonly logger = new Logger(AdminTransactionsController.name)
|
|
|
|
constructor(
|
|
@InjectRepository(TransactionEntity)
|
|
private readonly transactionRepository: Repository<TransactionEntity>,
|
|
@InjectRepository(PaymentWebhookEvent)
|
|
private readonly webhookEventRepository: Repository<PaymentWebhookEvent>,
|
|
) {}
|
|
|
|
/**
|
|
* GET /admin/transactions
|
|
*
|
|
* List all transactions, optionally filtered by subscriptionId.
|
|
*/
|
|
@Get()
|
|
async list(@Query('subscriptionId') subscriptionId?: string) {
|
|
const qb = this.transactionRepository
|
|
.createQueryBuilder('t')
|
|
.orderBy('t.createdAt', 'DESC')
|
|
|
|
if (subscriptionId) {
|
|
qb.where('t.relatedEntityId = :subscriptionId', { subscriptionId })
|
|
}
|
|
|
|
return qb.getMany()
|
|
}
|
|
|
|
/**
|
|
* GET /admin/transactions/audit-logs
|
|
*
|
|
* Returns webhook events as an audit trail.
|
|
* Supports filtering by entityType, entityId, and limit.
|
|
*/
|
|
@Get('audit-logs')
|
|
async getAuditLogs(
|
|
@Query('entityType') entityType?: string,
|
|
@Query('entityId') entityId?: string,
|
|
@Query('limit') limitStr?: string,
|
|
) {
|
|
const limit = Math.min(500, Math.max(1, Number(limitStr) || 100))
|
|
|
|
const qb = this.webhookEventRepository
|
|
.createQueryBuilder('w')
|
|
.orderBy('w.createdAt', 'DESC')
|
|
.take(limit)
|
|
|
|
if (entityType) {
|
|
qb.andWhere('w.eventType LIKE :entityType', { entityType: `%${entityType}%` })
|
|
}
|
|
|
|
if (entityId) {
|
|
qb.andWhere('w.idempotencyKey = :entityId', { entityId })
|
|
}
|
|
|
|
return qb.getMany()
|
|
}
|
|
|
|
/**
|
|
* POST /admin/transactions/:id/refund
|
|
*
|
|
* Issue a refund for a transaction.
|
|
* Marks the transaction as REFUNDED and records the refund metadata.
|
|
*/
|
|
@Post(':id/refund')
|
|
async refund(
|
|
@Param('id') id: string,
|
|
@Body() body: { reason: string; amount?: number },
|
|
) {
|
|
if (!body.reason) {
|
|
throw new BadRequestException('Refund reason is required')
|
|
}
|
|
|
|
const transaction = await this.transactionRepository.findOne({ where: { id } })
|
|
|
|
if (!transaction) {
|
|
throw new NotFoundException(`Transaction ${id} not found`)
|
|
}
|
|
|
|
if (transaction.status === TransactionStatus.REFUNDED) {
|
|
throw new BadRequestException(`Transaction ${id} is already refunded`)
|
|
}
|
|
|
|
const refundAmount = body.amount || Number(transaction.amountCents)
|
|
|
|
transaction.status = TransactionStatus.REFUNDED
|
|
transaction.metadata = {
|
|
...transaction.metadata,
|
|
refundReason: body.reason,
|
|
refundAmount,
|
|
refundedAt: new Date().toISOString(),
|
|
refundType: body.amount ? 'partial' : 'full',
|
|
}
|
|
|
|
const updated = await this.transactionRepository.save(transaction)
|
|
|
|
this.logger.log(
|
|
`Admin refunded transaction ${id}: amount=${refundAmount}, reason=${body.reason}`,
|
|
)
|
|
|
|
return updated
|
|
}
|
|
}
|