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, @InjectRepository(PaymentWebhookEvent) private readonly webhookEventRepository: Repository, ) {} /** * 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 } }