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

239 lines
7.5 KiB
TypeScript

import {
Controller,
Get,
} from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { TransactionEntity } from '@/src/entities/transaction.entity'
import { SubscriptionEntity } from '@/src/entities/subscription.entity'
import { PayoutEntity } from '@/src/entities/payout.entity'
import { EarningsEntryEntity } from '@/src/entities/earnings-entry.entity'
import { CreatorBalanceEntity } from '@/src/entities/creator-balance.entity'
import { TransactionStatus } from '@/providers/transaction.types'
import { SubscriptionStatus } from '@/providers/subscription.types'
import { PayoutStatus } from '@/providers/transaction.types'
/**
* Admin Analytics Controller
*
* Read-only analytics endpoints for the admin dashboard.
* Matches frontend adminAnalyticsApi contract.
*
* Routes:
* - GET /admin/analytics/dashboard — overview stats
* - GET /admin/analytics/revenue — revenue breakdown
* - GET /admin/analytics/subscriptions — subscription stats
* - GET /admin/analytics/transactions — transaction stats
* - GET /admin/analytics/payouts — payout stats
*/
@Controller('admin/analytics')
export class AdminAnalyticsController {
constructor(
@InjectRepository(TransactionEntity)
private readonly transactionRepository: Repository<TransactionEntity>,
@InjectRepository(SubscriptionEntity)
private readonly subscriptionRepository: Repository<SubscriptionEntity>,
@InjectRepository(PayoutEntity)
private readonly payoutRepository: Repository<PayoutEntity>,
@InjectRepository(EarningsEntryEntity)
private readonly earningsRepository: Repository<EarningsEntryEntity>,
@InjectRepository(CreatorBalanceEntity)
private readonly balanceRepository: Repository<CreatorBalanceEntity>,
) {}
/**
* GET /admin/analytics/dashboard
*
* High-level overview stats for the admin dashboard.
*/
@Get('dashboard')
async getDashboard() {
const [
totalTransactions,
totalSubscriptions,
activeSubscriptions,
totalPayouts,
pendingPayouts,
] = await Promise.all([
this.transactionRepository.count(),
this.subscriptionRepository.count(),
this.subscriptionRepository.count({ where: { status: SubscriptionStatus.ACTIVE } }),
this.payoutRepository.count(),
this.payoutRepository.count({ where: { status: PayoutStatus.PENDING } }),
])
const revenueResult = await this.transactionRepository
.createQueryBuilder('t')
.select('COALESCE(SUM(t.amountCents), 0)', 'totalRevenueCents')
.where('t.status = :status', { status: TransactionStatus.COMPLETED })
.getRawOne()
const earningsResult = await this.earningsRepository
.createQueryBuilder('e')
.select([
'COALESCE(SUM(e.grossCents), 0) AS "totalGrossCents"',
'COALESCE(SUM(e.platformFeeCents), 0) AS "totalPlatformFeeCents"',
'COALESCE(SUM(e.netCents), 0) AS "totalCreatorNetCents"',
])
.getRawOne()
return {
totalTransactions,
totalSubscriptions,
activeSubscriptions,
totalPayouts,
pendingPayouts,
totalRevenueCents: Number(revenueResult?.totalRevenueCents || 0),
totalGrossCents: Number(earningsResult?.totalGrossCents || 0),
totalPlatformFeeCents: Number(earningsResult?.totalPlatformFeeCents || 0),
totalCreatorNetCents: Number(earningsResult?.totalCreatorNetCents || 0),
}
}
/**
* GET /admin/analytics/revenue
*
* Revenue breakdown by type and time period.
*/
@Get('revenue')
async getRevenue() {
// Revenue by earnings type
const byType = await this.earningsRepository
.createQueryBuilder('e')
.select([
'e.type AS type',
'COUNT(*) AS count',
'COALESCE(SUM(e.grossCents), 0) AS "grossCents"',
'COALESCE(SUM(e.platformFeeCents), 0) AS "platformFeeCents"',
'COALESCE(SUM(e.netCents), 0) AS "netCents"',
])
.groupBy('e.type')
.getRawMany()
// Revenue last 30 days (daily)
const daily = await this.earningsRepository
.createQueryBuilder('e')
.select([
"DATE_TRUNC('day', e.created_at) AS date",
'COALESCE(SUM(e.grossCents), 0) AS "grossCents"',
'COALESCE(SUM(e.platformFeeCents), 0) AS "platformFeeCents"',
])
.where("e.created_at >= NOW() - INTERVAL '30 days'")
.groupBy("DATE_TRUNC('day', e.created_at)")
.orderBy('date', 'ASC')
.getRawMany()
return { byType, daily }
}
/**
* GET /admin/analytics/subscriptions
*
* Subscription stats: counts by status.
*/
@Get('subscriptions')
async getSubscriptions() {
const byStatus = await this.subscriptionRepository
.createQueryBuilder('s')
.select([
's.status AS status',
'COUNT(*) AS count',
])
.groupBy('s.status')
.getRawMany()
const totalActive = await this.subscriptionRepository.count({
where: { status: SubscriptionStatus.ACTIVE },
})
const totalCancelled = await this.subscriptionRepository.count({
where: { status: SubscriptionStatus.CANCELLED },
})
// MRR calculation: sum of active subscription prices
const mrrResult = await this.subscriptionRepository
.createQueryBuilder('s')
.select('COALESCE(SUM(s.priceCents), 0)', 'mrrCents')
.where('s.status = :status', { status: SubscriptionStatus.ACTIVE })
.getRawOne()
return {
byStatus,
totalActive,
totalCancelled,
mrrCents: Number(mrrResult?.mrrCents || 0),
}
}
/**
* GET /admin/analytics/transactions
*
* Transaction stats: counts and totals by status and type.
*/
@Get('transactions')
async getTransactions() {
const byStatus = await this.transactionRepository
.createQueryBuilder('t')
.select([
't.status AS status',
'COUNT(*) AS count',
'COALESCE(SUM(t.amountCents), 0) AS "totalCents"',
])
.groupBy('t.status')
.getRawMany()
const byType = await this.transactionRepository
.createQueryBuilder('t')
.select([
't.type AS type',
'COUNT(*) AS count',
'COALESCE(SUM(t.amountCents), 0) AS "totalCents"',
])
.groupBy('t.type')
.getRawMany()
return { byStatus, byType }
}
/**
* GET /admin/analytics/payouts
*
* Payout stats: counts and totals by status.
*/
@Get('payouts')
async getPayouts() {
const byStatus = await this.payoutRepository
.createQueryBuilder('p')
.select([
'p.status AS status',
'COUNT(*) AS count',
'COALESCE(SUM(p.amountCents), 0) AS "totalCents"',
])
.groupBy('p.status')
.getRawMany()
const totalPaidOutCents = await this.payoutRepository
.createQueryBuilder('p')
.select('COALESCE(SUM(p.amountCents), 0)', 'total')
.where('p.status = :status', { status: PayoutStatus.COMPLETED })
.getRawOne()
const totalCreatorBalances = await this.balanceRepository
.createQueryBuilder('b')
.select([
'COALESCE(SUM(b.availableCents), 0) AS "totalAvailableCents"',
'COALESCE(SUM(b.pendingCents), 0) AS "totalPendingCents"',
'COUNT(*) AS "totalCreators"',
])
.getRawOne()
return {
byStatus,
totalPaidOutCents: Number(totalPaidOutCents?.total || 0),
totalAvailableCents: Number(totalCreatorBalances?.totalAvailableCents || 0),
totalPendingCents: Number(totalCreatorBalances?.totalPendingCents || 0),
totalCreators: Number(totalCreatorBalances?.totalCreators || 0),
}
}
}