platform-codebase/features/payments/backend-api/admin/admin-transactions.controller.ts

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
}
}