Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions backend/src/audit/entities/audit-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,26 @@ export enum ActorType {
SYSTEM = 'system',
}

export enum SessionType {
ADMIN_DIRECT = 'admin_direct',
IMPERSONATION = 'impersonation',
API_KEY = 'api_key',
SYSTEM = 'system',
}

export enum RiskLevel {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical',
}

@Entity('audit_logs')
@Index(['resourceType', 'resourceId', 'createdAt'])
@Index(['actorId', 'createdAt'])
@Index(['action', 'createdAt'])
@Index(['impersonationSessionId', 'createdAt'])
@Index(['riskLevel', 'createdAt'])
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
Expand Down Expand Up @@ -54,6 +70,26 @@ export class AuditLog {
@Column({ name: 'correlation_id', type: 'varchar', length: 255, nullable: true, default: null })
correlationId!: string | null;

/** SHA-256 hash for tamper-evident chain */
@Column({ type: 'varchar', length: 64 })
hash!: string;

/** Hash of the immediately preceding AuditLog entry */
@Column({ name: 'previous_hash', type: 'varchar', length: 64, nullable: true, default: null })
previousHash!: string | null;

/** Type of session that generated this audit log */
@Column({ name: 'session_type', type: 'enum', enum: SessionType, default: SessionType.ADMIN_DIRECT })
sessionType!: SessionType;

/** UUID of impersonation session if sessionType is IMPERSONATION */
@Column({ name: 'impersonation_session_id', type: 'uuid', nullable: true, default: null })
impersonationSessionId!: string | null;

/** Risk level of the action */
@Column({ name: 'risk_level', type: 'enum', enum: RiskLevel, default: RiskLevel.LOW })
riskLevel!: RiskLevel;

/** Immutable — no updatedAt */
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
Expand Down
64 changes: 64 additions & 0 deletions backend/src/merchants/analytics/dto/merchant-overview.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { ApiProperty } from '@nestjs/swagger';

export class MerchantOverviewDto {
@ApiProperty({ description: 'Total revenue in USDC for the last 30 days' })
totalRevenueUsdc30d!: string;

@ApiProperty({ description: 'Total revenue in NGN for the last 30 days (at settled rates)' })
totalRevenueNgn30d!: string;

@ApiProperty({ description: 'Number of transactions in the last 30 days' })
transactionCount30d!: number;

@ApiProperty({ description: 'Number of unique customers in the last 30 days' })
uniqueCustomers30d!: number;

@ApiProperty({ description: 'Average transaction amount in USDC' })
avgTransactionUsdc!: string;

@ApiProperty({ description: 'Pending settlement amount in USDC' })
pendingSettlementUsdc!: string;

@ApiProperty({ description: 'Timestamp of last settlement', nullable: true })
lastSettledAt!: Date | null;

@ApiProperty({
description: 'Top payment method used',
enum: ['username_send', 'paylink', 'virtual_account']
})
topPaymentMethod!: string;
}

export class TopRecipientDto {
@ApiProperty({ description: 'Recipient username' })
username!: string;

@ApiProperty({ description: 'Total amount sent to this recipient' })
totalSent!: string;
}

export class RevenueDataPointDto {
@ApiProperty({ description: 'Timestamp for this data point' })
timestamp!: Date;

@ApiProperty({ description: 'Revenue amount in USDC' })
revenueUsdc!: string;

@ApiProperty({ description: 'Number of transactions in this period' })
transactionCount!: number;
}

export class RevenueTimelineDto {
@ApiProperty({ description: 'Granularity of the timeline' })
granularity!: 'hour' | 'day' | 'week' | 'month';

@ApiProperty({ description: 'Revenue data points' })
data!: RevenueDataPointDto[];
}

export enum RevenueGranularity {
HOUR = 'hour',
DAY = 'day',
WEEK = 'week',
MONTH = 'month',
}
92 changes: 92 additions & 0 deletions backend/src/merchants/analytics/merchant-analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
Controller,
Get,
Query,
Param,
UseGuards,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { MerchantAnalyticsService } from './merchant-analytics.service';
import { MerchantOverviewDto, RevenueTimelineDto, RevenueGranularity } from './dto/merchant-overview.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { IsOptional, IsEnum, IsDateString } from 'class-validator';

class RevenueTimelineQueryDto {
@IsEnum(RevenueGranularity)
@IsOptional()
granularity?: RevenueGranularity = RevenueGranularity.DAY;

@IsDateString()
@IsOptional()
dateFrom?: string;

@IsDateString()
@IsOptional()
dateTo?: string;
}

@ApiTags('Merchant Analytics')
@Controller({ path: 'merchants/analytics', version: '1' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class MerchantAnalyticsController {
constructor(private readonly analyticsService: MerchantAnalyticsService) {}

@Get('overview')
@ApiOperation({ summary: 'Get merchant overview statistics for the last 30 days' })
async getOverview(
@Req() req: { user: { id: string } },
): Promise<MerchantOverviewDto> {
return this.analyticsService.getOverview(req.user.id);
}

@Get('revenue-timeline')
@ApiOperation({ summary: 'Get revenue timeline with specified granularity' })
@ApiQuery({ name: 'granularity', enum: RevenueGranularity, required: false })
@ApiQuery({ name: 'dateFrom', type: String, required: false })
@ApiQuery({ name: 'dateTo', type: String, required: false })
async getRevenueTimeline(
@Req() req: { user: { id: string } },
@Query() query: RevenueTimelineQueryDto,
): Promise<RevenueTimelineDto> {
const dateFrom = query.dateFrom
? new Date(query.dateFrom)
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const dateTo = query.dateTo ? new Date(query.dateTo) : new Date();

return this.analyticsService.getRevenueTimeline(
req.user.id,
query.granularity || RevenueGranularity.DAY,
dateFrom,
dateTo,
);
}

@Get(':merchantId/overview')
@ApiOperation({ summary: 'Get specific merchant overview (admin only)' })
async getMerchantOverview(
@Param('merchantId') merchantId: string,
): Promise<MerchantOverviewDto> {
return this.analyticsService.getOverview(merchantId);
}

@Get(':merchantId/revenue-timeline')
@ApiOperation({ summary: 'Get specific merchant revenue timeline (admin only)' })
async getMerchantRevenueTimeline(
@Param('merchantId') merchantId: string,
@Query() query: RevenueTimelineQueryDto,
): Promise<RevenueTimelineDto> {
const dateFrom = query.dateFrom
? new Date(query.dateFrom)
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const dateTo = query.dateTo ? new Date(query.dateTo) : new Date();

return this.analyticsService.getRevenueTimeline(
merchantId,
query.granularity || RevenueGranularity.DAY,
dateFrom,
dateTo,
);
}
}
17 changes: 17 additions & 0 deletions backend/src/merchants/analytics/merchant-analytics.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Merchant } from '../entities/merchant.entity';
import { Transaction } from '../../transactions/entities/transaction.entity';
import { Settlement } from '../../settlement/entities/settlement.entity';
import { MerchantAnalyticsService } from './merchant-analytics.service';
import { MerchantAnalyticsController } from './merchant-analytics.controller';

@Module({
imports: [
TypeOrmModule.forFeature([Merchant, Transaction, Settlement]),
],
providers: [MerchantAnalyticsService],
controllers: [MerchantAnalyticsController],
exports: [MerchantAnalyticsService],
})
export class MerchantAnalyticsModule {}
Loading
Loading