239 lines
7.5 KiB
TypeScript
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),
|
|
}
|
|
}
|
|
}
|