feat: 全12服务DDD重构 + 公告定向推送系统 (移植自rwadurian)

## 一、DDD + Clean Architecture 重构 (全12服务)

对全部12个微服务(9 NestJS + 3 Go)实施严格的DDD分层:

### NestJS 服务 (9个):
- auth-service: JWT策略已在domain层
- user-service: 新增 5 个 repository interface + implementation,
  5 个 value objects (Email/Phone/KycLevel/Money), 3 组 domain events
- issuer-service: 新增 5 个 repository interface + implementation,
  3 个 value objects, 2 组 domain events, external AI client port
- clearing-service: 新增 5 个 repository interface + implementation,
  2 个 value objects, domain events
- compliance-service: 新增 7 个 repository interface + implementation,
  2 个 value objects, domain events, audit logger service
- ai-service: 新增 conversation repository + TypeORM entity,
  AI agent client port 移至 domain/ports/
- notification-service: 新增 notification repository interface +
  implementation, channel providers, value objects
- telemetry-service: 新增 3 个 repository interface + implementation
- admin-service: 新增 app-version repository interface + implementation

### Go 服务 (3个):
- trading-service: 重构为 domain/application/infrastructure 分层,
  新增 repository interface + postgres 实现, matching engine 移入
  application/service, 新增 value objects (Price/Quantity/OrderSide/OrderType),
  Kafka event publisher
- translate-service: 新增 repository interface + postgres 实现,
  value objects (Address/ChainType)
- chain-indexer: 新增 repository interface + postgres 实现,
  value objects (BlockHeight/TxHash), Kafka event publisher

### 关键模式:
- NestJS: Symbol token DI (provide: Symbol → useClass: Impl)
- Go: compile-time interface check (var _ Interface = (*Impl)(nil))
- TypeORM entity 保留 domain methods (pragmatic DDD)
- Repository interface 在 domain/, 实现在 infrastructure/persistence/

## 二、公告定向推送系统 (ported from rwadurian)

在 notification-service 中新增 Announcement 公告体系,
支持管理端向全体/标签/指定用户推送消息:

### 数据库:
- 038_create_announcements.sql: 5张新表
  - announcements (公告主表)
  - announcement_tag_targets (标签定向)
  - announcement_user_targets (用户定向)
  - announcement_reads (已读记录)
  - user_tags (用户标签)

### 三种定向模式:
- ALL: 推送给全体用户
- BY_TAG: 按标签筛选用户 (用户标签与公告标签有交集)
- SPECIFIC: 指定用户ID列表

### 新增文件 (15个):
- 5 个 TypeORM entity (Announcement + Read + TagTarget + UserTarget + UserTag)
- 2 个 repository interface (IAnnouncementRepository 11方法, IUserTagRepository 6方法)
- 2 个 repository 实现 (TypeORM QueryBuilder, targeting filter SQL)
- 2 个 application service (AnnouncementService, UserTagService)
- 2 个 DTO 文件 (announcement.dto.ts, user-tag.dto.ts)
- 1 个 controller 文件 (含3个controller: AdminAnnouncement/AdminUserTag/UserAnnouncement)
- 1 个 migration SQL

### API 端点:
管理端:
  POST   /admin/announcements       创建公告(含定向配置)
  GET    /admin/announcements       公告列表
  GET    /admin/announcements/:id   公告详情
  PUT    /admin/announcements/:id   更新公告
  DELETE /admin/announcements/:id   删除公告
  GET    /admin/user-tags           所有标签(含用户数)
  POST   /admin/user-tags/:userId   添加用户标签
  DELETE /admin/user-tags/:userId   移除用户标签
  PUT    /admin/user-tags/:userId/sync  同步用户标签

用户端:
  GET    /announcements             用户公告(按定向过滤+已读状态)
  GET    /announcements/unread-count  未读数
  PUT    /announcements/:id/read    标记已读
  PUT    /announcements/read-all    全部已读

### 设计决策:
- Announcement 与现有 Notification 并存 (双轨):
  Notification = 事件驱动1:1通知, Announcement = 管理端广播/定向
- rwadurian accountSequences → gcx userIds (UUID)
- rwadurian Prisma → gcx TypeORM

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-12 21:11:24 -08:00
parent 66781d47b3
commit acaec56849
265 changed files with 12391 additions and 2710 deletions

View File

@ -1,9 +1,15 @@
-- 005: Address mappings (translate-service core)
CREATE TABLE IF NOT EXISTS address_mappings (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
chain_address VARCHAR(42) NOT NULL UNIQUE,
signature TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
internal_address VARCHAR(128) NOT NULL,
chain_address VARCHAR(128) NOT NULL,
chain_type VARCHAR(20) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_address_mappings_chain_address ON address_mappings(chain_address);
CREATE UNIQUE INDEX IF NOT EXISTS idx_address_mappings_internal ON address_mappings(internal_address);
CREATE INDEX IF NOT EXISTS idx_address_mappings_chain ON address_mappings(chain_type, chain_address);
CREATE INDEX IF NOT EXISTS idx_address_mappings_user ON address_mappings(user_id);

View File

@ -0,0 +1,11 @@
-- 036: Indexed blockchain blocks (chain-indexer)
CREATE TABLE IF NOT EXISTS blocks (
height BIGINT PRIMARY KEY,
hash VARCHAR(128) NOT NULL UNIQUE,
tx_count INT NOT NULL DEFAULT 0,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_blocks_hash ON blocks(hash);
CREATE INDEX idx_blocks_created_at ON blocks(created_at DESC);

View File

@ -0,0 +1,14 @@
-- 037: Indexed on-chain transactions (chain-indexer)
CREATE TABLE IF NOT EXISTS chain_transactions (
hash VARCHAR(128) PRIMARY KEY,
block_height BIGINT NOT NULL REFERENCES blocks(height),
from_addr VARCHAR(128) NOT NULL,
to_addr VARCHAR(128) NOT NULL,
amount VARCHAR(78) NOT NULL DEFAULT '0',
status VARCHAR(20) NOT NULL DEFAULT 'confirmed',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_chain_tx_block ON chain_transactions(block_height);
CREATE INDEX idx_chain_tx_from ON chain_transactions(from_addr);
CREATE INDEX idx_chain_tx_to ON chain_transactions(to_addr);

View File

@ -0,0 +1,74 @@
-- =============================================================
-- 038: Announcement targeting system (ported from rwadurian)
-- 公告定向推送系统: 全体用户 / 按标签 / 指定用户
-- =============================================================
-- 1. 公告主表
CREATE TABLE IF NOT EXISTS announcements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'SYSTEM'
CHECK (type IN ('SYSTEM','ACTIVITY','REWARD','UPGRADE','ANNOUNCEMENT')),
priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL'
CHECK (priority IN ('LOW','NORMAL','HIGH','URGENT')),
target_type VARCHAR(10) NOT NULL DEFAULT 'ALL'
CHECK (target_type IN ('ALL','BY_TAG','SPECIFIC')),
target_config JSONB,
image_url VARCHAR(500),
link_url VARCHAR(500),
is_enabled BOOLEAN NOT NULL DEFAULT true,
published_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_announcements_type ON announcements(type);
CREATE INDEX idx_announcements_target ON announcements(target_type);
CREATE INDEX idx_announcements_enabled ON announcements(is_enabled, published_at);
-- 2. 公告标签定向表
CREATE TABLE IF NOT EXISTS announcement_tag_targets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
announcement_id UUID NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
tag VARCHAR(100) NOT NULL
);
CREATE INDEX idx_ann_tag_targets_ann ON announcement_tag_targets(announcement_id);
CREATE INDEX idx_ann_tag_targets_tag ON announcement_tag_targets(tag);
-- 3. 公告用户定向表
CREATE TABLE IF NOT EXISTS announcement_user_targets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
announcement_id UUID NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
user_id UUID NOT NULL
);
CREATE INDEX idx_ann_user_targets_ann ON announcement_user_targets(announcement_id);
CREATE INDEX idx_ann_user_targets_user ON announcement_user_targets(user_id);
-- 4. 公告已读记录表
CREATE TABLE IF NOT EXISTS announcement_reads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
announcement_id UUID NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
read_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (announcement_id, user_id)
);
CREATE INDEX idx_ann_reads_user ON announcement_reads(user_id);
CREATE INDEX idx_ann_reads_ann ON announcement_reads(announcement_id);
-- 5. 用户标签表
CREATE TABLE IF NOT EXISTS user_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
tag VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, tag)
);
CREATE INDEX idx_user_tags_user ON user_tags(user_id);
CREATE INDEX idx_user_tags_tag ON user_tags(tag);

View File

@ -10,7 +10,12 @@ import { AppVersion } from './domain/entities/app-version.entity';
import { AppVersionService } from './application/services/app-version.service';
import { FileStorageService } from './application/services/file-storage.service';
// Domain
import { APP_VERSION_REPOSITORY } from './domain/repositories/app-version.repository.interface';
import { PACKAGE_PARSER } from './domain/ports/package-parser.interface';
// Infrastructure
import { AppVersionRepository } from './infrastructure/persistence/app-version.repository';
import { PackageParserService } from './infrastructure/parsers/package-parser.service';
// Interface - Controllers
@ -48,9 +53,10 @@ import { HealthController } from './interface/http/controllers/health.controller
AdminVersionController,
],
providers: [
{ provide: APP_VERSION_REPOSITORY, useClass: AppVersionRepository },
AppVersionService,
FileStorageService,
PackageParserService,
{ provide: PACKAGE_PARSER, useClass: PackageParserService },
],
})
export class AdminModule {}

View File

@ -1,7 +1,5 @@
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AppVersion } from '../../domain/entities/app-version.entity';
import { Injectable, NotFoundException, ConflictException, Inject, Logger } from '@nestjs/common';
import { APP_VERSION_REPOSITORY, IAppVersionRepository } from '../../domain/repositories/app-version.repository.interface';
import { Platform } from '../../domain/enums/platform.enum';
@Injectable()
@ -9,15 +7,12 @@ export class AppVersionService {
private readonly logger = new Logger(AppVersionService.name);
constructor(
@InjectRepository(AppVersion) private readonly versionRepo: Repository<AppVersion>,
@Inject(APP_VERSION_REPOSITORY) private readonly versionRepo: IAppVersionRepository,
) {}
/** Check for update - mobile client API */
async checkUpdate(platform: Platform, currentVersionCode: number) {
const latest = await this.versionRepo.findOne({
where: { platform, isEnabled: true },
order: { versionCode: 'DESC' },
});
const latest = await this.versionRepo.findLatestEnabled(platform);
if (!latest || latest.versionCode <= currentVersionCode) {
return { needUpdate: false };
@ -40,19 +35,12 @@ export class AppVersionService {
/** List versions (admin) */
async listVersions(platform?: Platform, includeDisabled = false) {
const where: any = {};
if (platform) where.platform = platform;
if (!includeDisabled) where.isEnabled = true;
return this.versionRepo.find({
where,
order: { versionCode: 'DESC' },
});
return this.versionRepo.findByFilters(platform, includeDisabled);
}
/** Get version detail */
async getVersion(id: string) {
const version = await this.versionRepo.findOne({ where: { id } });
const version = await this.versionRepo.findById(id);
if (!version) throw new NotFoundException('Version not found');
return version;
}
@ -73,9 +61,7 @@ export class AppVersionService {
createdBy?: string;
}) {
// Check duplicate
const existing = await this.versionRepo.findOne({
where: { platform: data.platform, versionCode: data.versionCode },
});
const existing = await this.versionRepo.findByPlatformAndCode(data.platform, data.versionCode);
if (existing) {
throw new ConflictException(
`Version code ${data.versionCode} already exists for ${data.platform}`,
@ -108,14 +94,14 @@ export class AppVersionService {
/** Toggle enable/disable */
async toggleVersion(id: string, isEnabled: boolean) {
await this.getVersion(id); // Verify exists
await this.versionRepo.update(id, { isEnabled });
await this.versionRepo.updatePartial(id, { isEnabled });
return { success: true };
}
/** Delete version */
async deleteVersion(id: string) {
await this.getVersion(id); // Verify exists
await this.versionRepo.delete(id);
await this.versionRepo.deleteById(id);
return { success: true };
}

View File

@ -0,0 +1,20 @@
/**
* Package Parser domain port.
*
* Abstracts APK/IPA binary parsing for version management.
* The concrete implementation lives in infrastructure/parsers/.
*/
export const PACKAGE_PARSER = Symbol('IPackageParser');
export interface ParsedPackageInfo {
packageName: string;
versionCode: number;
versionName: string;
minSdkVersion?: string;
platform: 'ANDROID' | 'IOS';
}
export interface IPackageParser {
parse(buffer: Buffer, filename: string): Promise<ParsedPackageInfo>;
}

View File

@ -0,0 +1,15 @@
import { AppVersion } from '../entities/app-version.entity';
import { Platform } from '../enums/platform.enum';
export const APP_VERSION_REPOSITORY = Symbol('APP_VERSION_REPOSITORY');
export interface IAppVersionRepository {
findById(id: string): Promise<AppVersion | null>;
findLatestEnabled(platform: Platform): Promise<AppVersion | null>;
findByPlatformAndCode(platform: Platform, versionCode: number): Promise<AppVersion | null>;
findByFilters(platform?: Platform, includeDisabled?: boolean): Promise<AppVersion[]>;
create(data: Partial<AppVersion>): AppVersion;
save(entity: AppVersion): Promise<AppVersion>;
updatePartial(id: string, data: Partial<AppVersion>): Promise<void>;
deleteById(id: string): Promise<void>;
}

View File

@ -1,15 +1,8 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
interface ParsedPackageInfo {
packageName: string;
versionCode: number;
versionName: string;
minSdkVersion?: string;
platform: 'ANDROID' | 'IOS';
}
import { IPackageParser, ParsedPackageInfo } from '../../domain/ports/package-parser.interface';
@Injectable()
export class PackageParserService {
export class PackageParserService implements IPackageParser {
private readonly logger = new Logger(PackageParserService.name);
async parse(buffer: Buffer, filename: string): Promise<ParsedPackageInfo> {

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AppVersion } from '../../domain/entities/app-version.entity';
import { Platform } from '../../domain/enums/platform.enum';
import { IAppVersionRepository } from '../../domain/repositories/app-version.repository.interface';
@Injectable()
export class AppVersionRepository implements IAppVersionRepository {
constructor(
@InjectRepository(AppVersion) private readonly repo: Repository<AppVersion>,
) {}
async findById(id: string): Promise<AppVersion | null> {
return this.repo.findOne({ where: { id } });
}
async findLatestEnabled(platform: Platform): Promise<AppVersion | null> {
return this.repo.findOne({
where: { platform, isEnabled: true },
order: { versionCode: 'DESC' },
});
}
async findByPlatformAndCode(platform: Platform, versionCode: number): Promise<AppVersion | null> {
return this.repo.findOne({
where: { platform, versionCode },
});
}
async findByFilters(platform?: Platform, includeDisabled = false): Promise<AppVersion[]> {
const where: any = {};
if (platform) where.platform = platform;
if (!includeDisabled) where.isEnabled = true;
return this.repo.find({
where,
order: { versionCode: 'DESC' },
});
}
create(data: Partial<AppVersion>): AppVersion {
return this.repo.create(data);
}
async save(entity: AppVersion): Promise<AppVersion> {
return this.repo.save(entity);
}
async updatePartial(id: string, data: Partial<AppVersion>): Promise<void> {
await this.repo.update(id, data);
}
async deleteById(id: string): Promise<void> {
await this.repo.delete(id);
}
}

View File

@ -1,5 +1,5 @@
import {
Controller, Get, Post, Put, Patch, Delete,
Controller, Get, Post, Put, Patch, Delete, Inject,
Param, Query, Body, UseGuards, UseInterceptors, UploadedFile, Req,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
@ -7,7 +7,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagg
import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common';
import { AppVersionService } from '../../../application/services/app-version.service';
import { FileStorageService } from '../../../application/services/file-storage.service';
import { PackageParserService } from '../../../infrastructure/parsers/package-parser.service';
import { PACKAGE_PARSER, IPackageParser } from '../../../domain/ports/package-parser.interface';
import { Platform } from '../../../domain/enums/platform.enum';
@ApiTags('admin-versions')
@ -19,7 +19,7 @@ export class AdminVersionController {
constructor(
private readonly versionService: AppVersionService,
private readonly fileStorage: FileStorageService,
private readonly packageParser: PackageParserService,
@Inject(PACKAGE_PARSER) private readonly packageParser: IPackageParser,
) {}
@Get()

View File

@ -1,21 +1,73 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
// Domain entities
import { AiConversation } from './domain/entities/ai-conversation.entity';
// Domain repository tokens
import { CONVERSATION_REPOSITORY } from './domain/repositories/conversation.repository.interface';
// Infrastructure — persistence
import { ConversationRepository } from './infrastructure/persistence/conversation.repository';
// Infrastructure — external agents
import { AI_AGENT_CLIENT } from './domain/ports/ai-agent.client.interface';
import { AiAgentClient } from './infrastructure/external-agents/ai-agent.client';
// Application services
import { AiChatService } from './application/services/ai-chat.service';
import { AiCreditService } from './application/services/ai-credit.service';
import { AiPricingService } from './application/services/ai-pricing.service';
import { AiAnomalyService } from './application/services/ai-anomaly.service';
import { AdminAgentService } from './application/services/admin-agent.service';
// Interface — HTTP controllers
import { AiController } from './interface/http/controllers/ai.controller';
import { AdminAgentController } from './interface/http/controllers/admin-agent.controller';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres' as const,
host: config.get<string>('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 5432),
username: config.get<string>('DB_USER', 'genex'),
password: config.get<string>('DB_PASS', 'genex'),
database: config.get<string>('DB_NAME', 'genex_ai'),
entities: [AiConversation],
synchronize: false,
}),
}),
TypeOrmModule.forFeature([AiConversation]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_ACCESS_SECRET', 'dev-access-secret'),
}),
}),
],
controllers: [AiController, AdminAgentController],
providers: [AiChatService, AiCreditService, AiPricingService, AiAnomalyService, AdminAgentService],
providers: [
// Infrastructure bindings (interface → implementation)
{ provide: AI_AGENT_CLIENT, useClass: AiAgentClient },
{ provide: CONVERSATION_REPOSITORY, useClass: ConversationRepository },
// Application services
AiChatService,
AiCreditService,
AiPricingService,
AiAnomalyService,
AdminAgentService,
],
exports: [AiChatService, AiCreditService, AiPricingService, AiAnomalyService],
})
export class AiModule {}

View File

@ -1,18 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
export interface AgentStats {
sessionsToday: number;
totalSessions: number;
avgResponseTimeMs: number;
satisfactionScore: number;
activeModules: number;
}
export interface TopQuestion {
question: string;
count: number;
category: string;
}
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
AI_AGENT_CLIENT,
IAiAgentClient,
AgentStatsResponse,
AgentTopQuestion,
AgentSessionsResponse,
AgentSatisfactionMetrics,
} from '../../domain/ports/ai-agent.client.interface';
export interface AiModuleInfo {
id: string;
@ -24,44 +18,24 @@ export interface AiModuleInfo {
config: Record<string, any>;
}
export interface SessionSummary {
sessionId: string;
userId: string;
messageCount: number;
startedAt: string;
lastMessageAt: string;
satisfactionRating: number | null;
}
export interface SatisfactionMetrics {
averageRating: number;
totalRatings: number;
distribution: Record<string, number>;
trend: { period: string; rating: number }[];
}
@Injectable()
export class AdminAgentService {
private readonly logger = new Logger('AdminAgentService');
private readonly agentUrl: string;
private readonly apiKey: string;
private readonly logger = new Logger(AdminAgentService.name);
// In-memory module config (in production, this would come from DB or external service)
private moduleConfigs: Map<string, Record<string, any>> = new Map();
constructor() {
this.agentUrl = process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000';
this.apiKey = process.env.AI_AGENT_API_KEY || '';
}
constructor(
@Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient,
) {}
/**
* Get aggregate AI agent session stats.
* Tries external agent cluster first, falls back to mock data.
*/
async getStats(): Promise<AgentStats> {
async getStats(): Promise<AgentStatsResponse> {
try {
const res = await this.callAgent('/api/v1/admin/stats');
if (res) return res;
return await this.agentClient.getStats();
} catch (error) {
this.logger.warn(`External agent stats unavailable: ${error.message}`);
}
@ -79,10 +53,9 @@ export class AdminAgentService {
/**
* Get most commonly asked questions.
*/
async getTopQuestions(limit = 10): Promise<TopQuestion[]> {
async getTopQuestions(limit = 10): Promise<AgentTopQuestion[]> {
try {
const res = await this.callAgent(`/api/v1/admin/top-questions?limit=${limit}`);
if (res) return res;
return await this.agentClient.getTopQuestions(limit);
} catch (error) {
this.logger.warn(`External agent top-questions unavailable: ${error.message}`);
}
@ -108,7 +81,7 @@ export class AdminAgentService {
async getModules(): Promise<AiModuleInfo[]> {
const now = new Date().toISOString();
const modules: AiModuleInfo[] = [
return [
{
id: 'chat',
name: 'AI Chat Assistant',
@ -146,8 +119,6 @@ export class AdminAgentService {
config: this.moduleConfigs.get('anomaly') || { riskThreshold: 50, alertEnabled: true },
},
];
return modules;
}
/**
@ -163,30 +134,42 @@ export class AdminAgentService {
// Try to propagate to external agent
try {
await this.callAgent(`/api/v1/admin/modules/${moduleId}/config`, 'POST', merged);
await this.agentClient.configureModule(moduleId, merged);
} catch {
this.logger.warn(`Could not propagate config to external agent for module ${moduleId}`);
}
const modules = await this.getModules();
const updated = modules.find((m) => m.id === moduleId);
return updated || { id: moduleId, name: moduleId, description: '', enabled: true, accuracy: 0, lastUpdated: new Date().toISOString(), config: merged };
return (
updated || {
id: moduleId,
name: moduleId,
description: '',
enabled: true,
accuracy: 0,
lastUpdated: new Date().toISOString(),
config: merged,
}
);
}
/**
* Get recent AI chat sessions.
*/
async getSessions(page: number, limit: number): Promise<{ items: SessionSummary[]; total: number; page: number; limit: number }> {
async getSessions(
page: number,
limit: number,
): Promise<AgentSessionsResponse> {
try {
const res = await this.callAgent(`/api/v1/admin/sessions?page=${page}&limit=${limit}`);
if (res) return res;
return await this.agentClient.getSessions(page, limit);
} catch (error) {
this.logger.warn(`External agent sessions unavailable: ${error.message}`);
}
// Mock session data
const now = Date.now();
const mockSessions: SessionSummary[] = Array.from({ length: Math.min(limit, 10) }, (_, i) => ({
const items = Array.from({ length: Math.min(limit, 10) }, (_, i) => ({
sessionId: `session-${1000 - i - (page - 1) * limit}`,
userId: `user-${Math.floor(Math.random() * 500) + 1}`,
messageCount: Math.floor(Math.random() * 20) + 1,
@ -195,16 +178,15 @@ export class AdminAgentService {
satisfactionRating: Math.random() > 0.3 ? Math.floor(Math.random() * 2) + 4 : null,
}));
return { items: mockSessions, total: 100, page, limit };
return { items, total: 100, page, limit };
}
/**
* Get satisfaction metrics for AI chat sessions.
*/
async getSatisfactionMetrics(): Promise<SatisfactionMetrics> {
async getSatisfactionMetrics(): Promise<AgentSatisfactionMetrics> {
try {
const res = await this.callAgent('/api/v1/admin/satisfaction');
if (res) return res;
return await this.agentClient.getSatisfactionMetrics();
} catch (error) {
this.logger.warn(`External agent satisfaction unavailable: ${error.message}`);
}
@ -230,32 +212,4 @@ export class AdminAgentService {
],
};
}
/**
* Call the external AI agent cluster.
*/
private async callAgent(path: string, method = 'GET', body?: any): Promise<any> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
},
signal: controller.signal,
};
if (body && method !== 'GET') {
options.body = JSON.stringify(body);
}
const res = await fetch(`${this.agentUrl}${path}`, options);
if (!res.ok) throw new Error(`Agent returned ${res.status}`);
return res.json();
} finally {
clearTimeout(timeoutId);
}
}
}

View File

@ -1,37 +1,34 @@
import { Injectable, Logger } from '@nestjs/common';
export interface AnomalyCheckRequest {
userId: string;
transactionType: string;
amount: number;
metadata?: Record<string, any>;
}
export interface AnomalyCheckResponse {
isAnomalous: boolean;
riskScore: number;
reasons: string[];
}
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
AI_AGENT_CLIENT,
IAiAgentClient,
AgentAnomalyRequest,
} from '../../domain/ports/ai-agent.client.interface';
import { AnomalyDetectRequestDto, AnomalyDetectResponseDto } from '../../interface/http/dto/anomaly.dto';
@Injectable()
export class AiAnomalyService {
private readonly logger = new Logger('AiAnomaly');
private readonly agentUrl: string;
private readonly apiKey: string;
private readonly logger = new Logger(AiAnomalyService.name);
constructor() {
this.agentUrl = process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000';
this.apiKey = process.env.AI_AGENT_API_KEY || '';
}
constructor(
@Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient,
) {}
async check(req: AnomalyDetectRequestDto): Promise<AnomalyDetectResponseDto> {
const agentReq: AgentAnomalyRequest = {
userId: req.userId,
transactionType: req.transactionType,
amount: req.amount,
metadata: req.metadata,
};
async check(req: AnomalyCheckRequest): Promise<AnomalyCheckResponse> {
try {
const res = await fetch(`${this.agentUrl}/api/v1/anomaly/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}) },
body: JSON.stringify(req),
});
if (res.ok) return res.json();
const raw = await this.agentClient.anomalyDetect(agentReq);
return {
isAnomalous: raw.isAnomalous,
riskScore: raw.riskScore,
reasons: raw.reasons,
};
} catch (error) {
this.logger.warn(`External AI anomaly detection unavailable: ${error.message}`);
}
@ -40,12 +37,18 @@ export class AiAnomalyService {
return this.localAnomalyCheck(req);
}
private localAnomalyCheck(req: AnomalyCheckRequest): AnomalyCheckResponse {
private localAnomalyCheck(req: AnomalyDetectRequestDto): AnomalyDetectResponseDto {
const reasons: string[] = [];
let riskScore = 0;
if (req.amount >= 10000) { reasons.push('Large transaction amount'); riskScore += 40; }
if (req.amount >= 2500 && req.amount < 3000) { reasons.push('Near structuring threshold'); riskScore += 30; }
if (req.amount >= 10000) {
reasons.push('Large transaction amount');
riskScore += 40;
}
if (req.amount >= 2500 && req.amount < 3000) {
reasons.push('Near structuring threshold');
riskScore += 30;
}
return {
isAnomalous: riskScore >= 50,

View File

@ -1,43 +1,33 @@
import { Injectable, Logger } from '@nestjs/common';
export interface ChatRequest {
userId: string;
message: string;
sessionId?: string;
context?: Record<string, any>;
}
export interface ChatResponse {
reply: string;
sessionId: string;
suggestions?: string[];
}
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
AI_AGENT_CLIENT,
IAiAgentClient,
AgentChatRequest,
} from '../../domain/ports/ai-agent.client.interface';
import { ChatRequestDto, ChatResponseDto } from '../../interface/http/dto/chat.dto';
@Injectable()
export class AiChatService {
private readonly logger = new Logger('AiChat');
private readonly agentUrl: string;
private readonly apiKey: string;
private readonly timeout: number;
private readonly logger = new Logger(AiChatService.name);
constructor() {
this.agentUrl = process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000';
this.apiKey = process.env.AI_AGENT_API_KEY || '';
this.timeout = parseInt(process.env.AI_AGENT_TIMEOUT || '30000', 10);
}
constructor(
@Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient,
) {}
async chat(req: ChatRequestDto): Promise<ChatResponseDto> {
const agentReq: AgentChatRequest = {
userId: req.userId,
message: req.message,
sessionId: req.sessionId,
context: req.context,
};
async chat(req: ChatRequest): Promise<ChatResponse> {
try {
const response = await this.callAgent('/api/v1/chat', {
user_id: req.userId,
message: req.message,
session_id: req.sessionId,
context: req.context,
});
const response = await this.agentClient.chat(agentReq);
return {
reply: response.reply || response.message || 'I apologize, I could not process your request.',
sessionId: response.session_id || req.sessionId || `session-${Date.now()}`,
suggestions: response.suggestions || [],
reply: response.reply || 'I apologize, I could not process your request.',
sessionId: response.sessionId,
suggestions: response.suggestions,
};
} catch (error) {
this.logger.error(`Chat failed: ${error.message}`);
@ -49,21 +39,4 @@ export class AiChatService {
};
}
}
private async callAgent(path: string, body: any): Promise<any> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const res = await fetch(`${this.agentUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}) },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) throw new Error(`Agent returned ${res.status}`);
return res.json();
} finally {
clearTimeout(timeoutId);
}
}
}

View File

@ -1,55 +1,45 @@
import { Injectable, Logger } from '@nestjs/common';
export interface CreditScoreRequest {
userId: string;
issuerId?: string;
redemptionRate: number;
breakageRate: number;
tenureDays: number;
satisfactionScore: number;
}
export interface CreditScoreResponse {
score: number;
level: string;
factors: Record<string, number>;
recommendations?: string[];
}
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
AI_AGENT_CLIENT,
IAiAgentClient,
AgentCreditScoreRequest,
} from '../../domain/ports/ai-agent.client.interface';
import { CreditScoreRequestDto, CreditScoreResponseDto } from '../../interface/http/dto/credit-score.dto';
import { CreditScoreResult } from '../../domain/value-objects/credit-score-result.vo';
@Injectable()
export class AiCreditService {
private readonly logger = new Logger('AiCredit');
private readonly agentUrl: string;
private readonly apiKey: string;
private readonly logger = new Logger(AiCreditService.name);
constructor() {
this.agentUrl = process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000';
this.apiKey = process.env.AI_AGENT_API_KEY || '';
}
constructor(
@Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient,
) {}
async getScore(req: CreditScoreRequestDto): Promise<CreditScoreResponseDto> {
const agentReq: AgentCreditScoreRequest = {
userId: req.userId,
issuerId: req.issuerId,
redemptionRate: req.redemptionRate,
breakageRate: req.breakageRate,
tenureDays: req.tenureDays,
satisfactionScore: req.satisfactionScore,
};
async getScore(req: CreditScoreRequest): Promise<CreditScoreResponse> {
try {
const res = await fetch(`${this.agentUrl}/api/v1/credit/score`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}) },
body: JSON.stringify(req),
});
if (res.ok) return res.json();
const raw = await this.agentClient.creditScore(agentReq);
const result = CreditScoreResult.fromResponse(raw);
return result.toPlain();
} catch (error) {
this.logger.warn(`External AI credit scoring unavailable: ${error.message}`);
}
// Fallback: local 4-factor calculation
return this.localCreditScore(req);
}
private localCreditScore(req: CreditScoreRequest): CreditScoreResponse {
const r = Math.min(100, req.redemptionRate * 100) * 0.35;
const b = Math.min(100, (1 - req.breakageRate) * 100) * 0.25;
const t = Math.min(100, (req.tenureDays / 365) * 100) * 0.20;
const s = Math.min(100, req.satisfactionScore) * 0.20;
const score = Math.round(r + b + t + s);
const level = score >= 80 ? 'A' : score >= 60 ? 'B' : score >= 40 ? 'C' : score >= 20 ? 'D' : 'F';
return { score, level, factors: { redemption: r, breakage: b, tenure: t, satisfaction: s } };
// Fallback: local 4-factor calculation via domain value object
const result = CreditScoreResult.fromFactors({
redemptionRate: req.redemptionRate,
breakageRate: req.breakageRate,
tenureDays: req.tenureDays,
satisfactionScore: req.satisfactionScore,
});
return result.toPlain();
}
}

View File

@ -1,57 +1,46 @@
import { Injectable, Logger } from '@nestjs/common';
export interface PricingSuggestionRequest {
couponId: string;
faceValue: number;
daysToExpiry: number;
totalDays: number;
redemptionRate: number;
liquidityPremium: number;
}
export interface PricingSuggestionResponse {
suggestedPrice: number;
confidence: number;
factors: Record<string, number>;
}
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
AI_AGENT_CLIENT,
IAiAgentClient,
AgentPricingRequest,
} from '../../domain/ports/ai-agent.client.interface';
import { PricingRequestDto, PricingResponseDto } from '../../interface/http/dto/pricing.dto';
import { PricingSuggestion } from '../../domain/value-objects/pricing-suggestion.vo';
@Injectable()
export class AiPricingService {
private readonly logger = new Logger('AiPricing');
private readonly agentUrl: string;
private readonly apiKey: string;
private readonly logger = new Logger(AiPricingService.name);
constructor() {
this.agentUrl = process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000';
this.apiKey = process.env.AI_AGENT_API_KEY || '';
}
constructor(
@Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient,
) {}
async getSuggestion(req: PricingRequestDto): Promise<PricingResponseDto> {
const agentReq: AgentPricingRequest = {
couponId: req.couponId,
faceValue: req.faceValue,
daysToExpiry: req.daysToExpiry,
totalDays: req.totalDays,
redemptionRate: req.redemptionRate,
liquidityPremium: req.liquidityPremium,
};
async getSuggestion(req: PricingSuggestionRequest): Promise<PricingSuggestionResponse> {
try {
const res = await fetch(`${this.agentUrl}/api/v1/pricing/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}) },
body: JSON.stringify(req),
});
if (res.ok) return res.json();
const raw = await this.agentClient.pricing(agentReq);
const result = PricingSuggestion.fromResponse(raw);
return result.toPlain();
} catch (error) {
this.logger.warn(`External AI pricing unavailable: ${error.message}`);
}
// Fallback: local 3-factor pricing model P = F × (1 - dt - rc - lp)
return this.localPricing(req);
}
private localPricing(req: PricingSuggestionRequest): PricingSuggestionResponse {
const dt = req.totalDays > 0 ? Math.max(0, 1 - req.daysToExpiry / req.totalDays) * 0.3 : 0;
const rc = (1 - req.redemptionRate) * 0.2;
const lp = req.liquidityPremium;
const discount = dt + rc + lp;
const price = Math.max(req.faceValue * 0.1, req.faceValue * (1 - discount));
return {
suggestedPrice: Math.round(price * 100) / 100,
confidence: 0.7,
factors: { timeDecay: dt, redemptionCredit: rc, liquidityPremium: lp },
};
// Fallback: local 3-factor pricing model via domain value object
const result = PricingSuggestion.fromLocalModel({
faceValue: req.faceValue,
daysToExpiry: req.daysToExpiry,
totalDays: req.totalDays,
redemptionRate: req.redemptionRate,
liquidityPremium: req.liquidityPremium,
});
return result.toPlain();
}
}

View File

@ -0,0 +1,70 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
export interface ConversationMessage {
role: 'user' | 'assistant';
content: string;
suggestions?: string[];
timestamp: Date;
}
@Entity('ai_conversations')
@Index('idx_ai_conversations_user', ['userId'])
@Index('idx_ai_conversations_session', ['sessionId'], { unique: true })
export class AiConversation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'session_id', type: 'varchar', length: 100 })
sessionId: string;
@Column({ type: 'jsonb', default: '[]' })
messages: ConversationMessage[];
@Column({ name: 'satisfaction_rating', type: 'smallint', nullable: true })
satisfactionRating: number | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// ── Domain Methods ──────────────────────────────────────────────────────────
addUserMessage(content: string): void {
this.messages.push({
role: 'user',
content,
timestamp: new Date(),
});
}
addAssistantMessage(content: string, suggestions?: string[]): void {
this.messages.push({
role: 'assistant',
content,
suggestions,
timestamp: new Date(),
});
}
rate(rating: number): void {
if (rating < 1 || rating > 5) {
throw new Error('Satisfaction rating must be between 1 and 5');
}
this.satisfactionRating = rating;
}
get messageCount(): number {
return this.messages.length;
}
get lastMessageAt(): Date {
return this.messages.length > 0
? this.messages[this.messages.length - 1].timestamp
: this.createdAt;
}
}

View File

@ -0,0 +1,42 @@
/**
* AI Domain Events
*
* Emitted when significant domain actions occur within the AI service.
* These can be published to Kafka or consumed by other bounded contexts.
*/
export interface ChatCompletedEvent {
userId: string;
sessionId: string;
messageCount: number;
hasError: boolean;
responseTimeMs: number;
timestamp: string;
}
export interface CreditScoreCalculatedEvent {
userId: string;
issuerId: string | null;
score: number;
level: string;
source: 'external' | 'local-fallback';
timestamp: string;
}
export interface AnomalyDetectedEvent {
userId: string;
transactionType: string;
amount: number;
riskScore: number;
reasons: string[];
source: 'external' | 'local-fallback';
timestamp: string;
}
export interface PricingSuggestionGeneratedEvent {
couponId: string;
suggestedPrice: number;
confidence: number;
source: 'external' | 'local-fallback';
timestamp: string;
}

View File

@ -0,0 +1,146 @@
/**
* AI Agent Client Interface
*
* Anti-corruption layer abstraction for all external AI agent cluster communication.
* Application services depend on this interface, never on concrete HTTP implementations.
*/
export const AI_AGENT_CLIENT = Symbol('IAiAgentClient');
// ── Chat ──────────────────────────────────────────────────────────────────────
export interface AgentChatRequest {
userId: string;
message: string;
sessionId?: string;
context?: Record<string, any>;
}
export interface AgentChatResponse {
reply: string;
sessionId: string;
suggestions: string[];
}
// ── Credit Scoring ────────────────────────────────────────────────────────────
export interface AgentCreditScoreRequest {
userId: string;
issuerId?: string;
redemptionRate: number;
breakageRate: number;
tenureDays: number;
satisfactionScore: number;
}
export interface AgentCreditScoreResponse {
score: number;
level: string;
factors: Record<string, number>;
recommendations?: string[];
}
// ── Pricing ───────────────────────────────────────────────────────────────────
export interface AgentPricingRequest {
couponId: string;
faceValue: number;
daysToExpiry: number;
totalDays: number;
redemptionRate: number;
liquidityPremium: number;
}
export interface AgentPricingResponse {
suggestedPrice: number;
confidence: number;
factors: Record<string, number>;
}
// ── Anomaly Detection ─────────────────────────────────────────────────────────
export interface AgentAnomalyRequest {
userId: string;
transactionType: string;
amount: number;
metadata?: Record<string, any>;
}
export interface AgentAnomalyResponse {
isAnomalous: boolean;
riskScore: number;
reasons: string[];
}
// ── Admin ─────────────────────────────────────────────────────────────────────
export interface AgentStatsResponse {
sessionsToday: number;
totalSessions: number;
avgResponseTimeMs: number;
satisfactionScore: number;
activeModules: number;
}
export interface AgentTopQuestion {
question: string;
count: number;
category: string;
}
export interface AgentSessionSummary {
sessionId: string;
userId: string;
messageCount: number;
startedAt: string;
lastMessageAt: string;
satisfactionRating: number | null;
}
export interface AgentSessionsResponse {
items: AgentSessionSummary[];
total: number;
page: number;
limit: number;
}
export interface AgentSatisfactionMetrics {
averageRating: number;
totalRatings: number;
distribution: Record<string, number>;
trend: { period: string; rating: number }[];
}
// ── Interface ─────────────────────────────────────────────────────────────────
export interface IAiAgentClient {
/** Send a chat message to the AI agent. */
chat(req: AgentChatRequest): Promise<AgentChatResponse>;
/** Request a credit score from the AI agent. */
creditScore(req: AgentCreditScoreRequest): Promise<AgentCreditScoreResponse>;
/** Request a pricing suggestion from the AI agent. */
pricing(req: AgentPricingRequest): Promise<AgentPricingResponse>;
/** Request anomaly detection from the AI agent. */
anomalyDetect(req: AgentAnomalyRequest): Promise<AgentAnomalyResponse>;
/** Get aggregate agent stats (admin). */
getStats(): Promise<AgentStatsResponse>;
/** Get top questions (admin). */
getTopQuestions(limit: number): Promise<AgentTopQuestion[]>;
/** Get sessions (admin). */
getSessions(page: number, limit: number): Promise<AgentSessionsResponse>;
/** Get satisfaction metrics (admin). */
getSatisfactionMetrics(): Promise<AgentSatisfactionMetrics>;
/** Propagate module config to external agent. */
configureModule(moduleId: string, config: Record<string, any>): Promise<void>;
/** Check external agent health. */
healthCheck(): Promise<boolean>;
}

View File

@ -0,0 +1,11 @@
import { AiConversation } from '../entities/ai-conversation.entity';
export interface IConversationRepository {
findById(id: string): Promise<AiConversation | null>;
findBySessionId(sessionId: string): Promise<AiConversation | null>;
findByUserId(userId: string, skip: number, take: number): Promise<[AiConversation[], number]>;
save(conversation: AiConversation): Promise<AiConversation>;
countByUserId(userId: string): Promise<number>;
}
export const CONVERSATION_REPOSITORY = Symbol('IConversationRepository');

View File

@ -0,0 +1,102 @@
/**
* Credit Score Result Value Object
*
* Encapsulates a credit score with its contributing factors and risk level.
* Immutable once created; all validation happens at construction time.
*/
export class CreditScoreResult {
readonly score: number;
readonly level: CreditLevel;
readonly factors: CreditScoreFactors;
readonly recommendations: string[];
private constructor(props: {
score: number;
level: CreditLevel;
factors: CreditScoreFactors;
recommendations: string[];
}) {
this.score = props.score;
this.level = props.level;
this.factors = props.factors;
this.recommendations = props.recommendations;
}
/**
* Create a CreditScoreResult from raw factor values.
* Applies weighting: redemption 35%, breakage 25%, tenure 20%, satisfaction 20%.
*/
static fromFactors(params: {
redemptionRate: number;
breakageRate: number;
tenureDays: number;
satisfactionScore: number;
}): CreditScoreResult {
const r = Math.min(100, params.redemptionRate * 100) * 0.35;
const b = Math.min(100, (1 - params.breakageRate) * 100) * 0.25;
const t = Math.min(100, (params.tenureDays / 365) * 100) * 0.20;
const s = Math.min(100, params.satisfactionScore) * 0.20;
const score = Math.round(r + b + t + s);
const level = CreditScoreResult.scoreToLevel(score);
return new CreditScoreResult({
score,
level,
factors: { redemption: r, breakage: b, tenure: t, satisfaction: s },
recommendations: [],
});
}
/**
* Reconstitute from external AI agent response data.
*/
static fromResponse(data: {
score: number;
level: string;
factors: Record<string, number>;
recommendations?: string[];
}): CreditScoreResult {
return new CreditScoreResult({
score: data.score,
level: (data.level as CreditLevel) || CreditScoreResult.scoreToLevel(data.score),
factors: {
redemption: data.factors.redemption ?? 0,
breakage: data.factors.breakage ?? 0,
tenure: data.factors.tenure ?? 0,
satisfaction: data.factors.satisfaction ?? 0,
},
recommendations: data.recommendations ?? [],
});
}
private static scoreToLevel(score: number): CreditLevel {
if (score >= 80) return 'A';
if (score >= 60) return 'B';
if (score >= 40) return 'C';
if (score >= 20) return 'D';
return 'F';
}
toPlain(): {
score: number;
level: string;
factors: Record<string, number>;
recommendations: string[];
} {
return {
score: this.score,
level: this.level,
factors: { ...this.factors },
recommendations: [...this.recommendations],
};
}
}
export type CreditLevel = 'A' | 'B' | 'C' | 'D' | 'F';
export interface CreditScoreFactors {
redemption: number;
breakage: number;
tenure: number;
satisfaction: number;
}

View File

@ -0,0 +1,85 @@
/**
* Pricing Suggestion Value Object
*
* Encapsulates a suggested price with confidence level and contributing factors.
* Immutable once created; all validation happens at construction time.
*/
export class PricingSuggestion {
readonly suggestedPrice: number;
readonly confidence: number;
readonly factors: PricingFactors;
private constructor(props: {
suggestedPrice: number;
confidence: number;
factors: PricingFactors;
}) {
this.suggestedPrice = props.suggestedPrice;
this.confidence = props.confidence;
this.factors = props.factors;
}
/**
* Create a PricingSuggestion using the local 3-factor pricing model.
* Formula: P = F x (1 - dt - rc - lp)
*/
static fromLocalModel(params: {
faceValue: number;
daysToExpiry: number;
totalDays: number;
redemptionRate: number;
liquidityPremium: number;
}): PricingSuggestion {
const dt =
params.totalDays > 0
? Math.max(0, 1 - params.daysToExpiry / params.totalDays) * 0.3
: 0;
const rc = (1 - params.redemptionRate) * 0.2;
const lp = params.liquidityPremium;
const discount = dt + rc + lp;
const price = Math.max(params.faceValue * 0.1, params.faceValue * (1 - discount));
return new PricingSuggestion({
suggestedPrice: Math.round(price * 100) / 100,
confidence: 0.7,
factors: { timeDecay: dt, redemptionCredit: rc, liquidityPremium: lp },
});
}
/**
* Reconstitute from external AI agent response data.
*/
static fromResponse(data: {
suggestedPrice: number;
confidence: number;
factors: Record<string, number>;
}): PricingSuggestion {
return new PricingSuggestion({
suggestedPrice: data.suggestedPrice,
confidence: data.confidence,
factors: {
timeDecay: data.factors.timeDecay ?? 0,
redemptionCredit: data.factors.redemptionCredit ?? 0,
liquidityPremium: data.factors.liquidityPremium ?? 0,
},
});
}
toPlain(): {
suggestedPrice: number;
confidence: number;
factors: Record<string, number>;
} {
return {
suggestedPrice: this.suggestedPrice,
confidence: this.confidence,
factors: { ...this.factors },
};
}
}
export interface PricingFactors {
timeDecay: number;
redemptionCredit: number;
liquidityPremium: number;
}

View File

@ -0,0 +1,146 @@
/**
* AI Agent Client Interface
*
* Anti-corruption layer abstraction for all external AI agent cluster communication.
* Application services depend on this interface, never on concrete HTTP implementations.
*/
export const AI_AGENT_CLIENT = Symbol('IAiAgentClient');
// ── Chat ──────────────────────────────────────────────────────────────────────
export interface AgentChatRequest {
userId: string;
message: string;
sessionId?: string;
context?: Record<string, any>;
}
export interface AgentChatResponse {
reply: string;
sessionId: string;
suggestions: string[];
}
// ── Credit Scoring ────────────────────────────────────────────────────────────
export interface AgentCreditScoreRequest {
userId: string;
issuerId?: string;
redemptionRate: number;
breakageRate: number;
tenureDays: number;
satisfactionScore: number;
}
export interface AgentCreditScoreResponse {
score: number;
level: string;
factors: Record<string, number>;
recommendations?: string[];
}
// ── Pricing ───────────────────────────────────────────────────────────────────
export interface AgentPricingRequest {
couponId: string;
faceValue: number;
daysToExpiry: number;
totalDays: number;
redemptionRate: number;
liquidityPremium: number;
}
export interface AgentPricingResponse {
suggestedPrice: number;
confidence: number;
factors: Record<string, number>;
}
// ── Anomaly Detection ─────────────────────────────────────────────────────────
export interface AgentAnomalyRequest {
userId: string;
transactionType: string;
amount: number;
metadata?: Record<string, any>;
}
export interface AgentAnomalyResponse {
isAnomalous: boolean;
riskScore: number;
reasons: string[];
}
// ── Admin ─────────────────────────────────────────────────────────────────────
export interface AgentStatsResponse {
sessionsToday: number;
totalSessions: number;
avgResponseTimeMs: number;
satisfactionScore: number;
activeModules: number;
}
export interface AgentTopQuestion {
question: string;
count: number;
category: string;
}
export interface AgentSessionSummary {
sessionId: string;
userId: string;
messageCount: number;
startedAt: string;
lastMessageAt: string;
satisfactionRating: number | null;
}
export interface AgentSessionsResponse {
items: AgentSessionSummary[];
total: number;
page: number;
limit: number;
}
export interface AgentSatisfactionMetrics {
averageRating: number;
totalRatings: number;
distribution: Record<string, number>;
trend: { period: string; rating: number }[];
}
// ── Interface ─────────────────────────────────────────────────────────────────
export interface IAiAgentClient {
/** Send a chat message to the AI agent. */
chat(req: AgentChatRequest): Promise<AgentChatResponse>;
/** Request a credit score from the AI agent. */
creditScore(req: AgentCreditScoreRequest): Promise<AgentCreditScoreResponse>;
/** Request a pricing suggestion from the AI agent. */
pricing(req: AgentPricingRequest): Promise<AgentPricingResponse>;
/** Request anomaly detection from the AI agent. */
anomalyDetect(req: AgentAnomalyRequest): Promise<AgentAnomalyResponse>;
/** Get aggregate agent stats (admin). */
getStats(): Promise<AgentStatsResponse>;
/** Get top questions (admin). */
getTopQuestions(limit: number): Promise<AgentTopQuestion[]>;
/** Get sessions (admin). */
getSessions(page: number, limit: number): Promise<AgentSessionsResponse>;
/** Get satisfaction metrics (admin). */
getSatisfactionMetrics(): Promise<AgentSatisfactionMetrics>;
/** Propagate module config to external agent. */
configureModule(moduleId: string, config: Record<string, any>): Promise<void>;
/** Check external agent health. */
healthCheck(): Promise<boolean>;
}

View File

@ -0,0 +1,145 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
IAiAgentClient,
AgentChatRequest,
AgentChatResponse,
AgentCreditScoreRequest,
AgentCreditScoreResponse,
AgentPricingRequest,
AgentPricingResponse,
AgentAnomalyRequest,
AgentAnomalyResponse,
AgentStatsResponse,
AgentTopQuestion,
AgentSessionsResponse,
AgentSatisfactionMetrics,
} from '../../domain/ports/ai-agent.client.interface';
/**
* Concrete implementation of IAiAgentClient.
*
* Encapsulates ALL HTTP communication with the external AI agent cluster.
* This is the single place where fetch() calls, timeouts, headers, and
* API key handling live. No other layer should make direct HTTP calls.
*/
@Injectable()
export class AiAgentClient implements IAiAgentClient {
private readonly logger = new Logger(AiAgentClient.name);
private readonly agentUrl: string;
private readonly apiKey: string;
private readonly timeout: number;
constructor(private readonly config: ConfigService) {
this.agentUrl = this.config.get<string>('AI_AGENT_CLUSTER_URL', 'http://localhost:8000');
this.apiKey = this.config.get<string>('AI_AGENT_API_KEY', '');
this.timeout = this.config.get<number>('AI_AGENT_TIMEOUT', 30000);
}
// ── Core AI capabilities ──────────────────────────────────────────────────
async chat(req: AgentChatRequest): Promise<AgentChatResponse> {
const raw = await this.post('/api/v1/chat', {
user_id: req.userId,
message: req.message,
session_id: req.sessionId,
context: req.context,
});
return {
reply: raw.reply || raw.message || '',
sessionId: raw.session_id || req.sessionId || `session-${Date.now()}`,
suggestions: raw.suggestions || [],
};
}
async creditScore(req: AgentCreditScoreRequest): Promise<AgentCreditScoreResponse> {
return this.post('/api/v1/credit/score', req);
}
async pricing(req: AgentPricingRequest): Promise<AgentPricingResponse> {
return this.post('/api/v1/pricing/suggest', req);
}
async anomalyDetect(req: AgentAnomalyRequest): Promise<AgentAnomalyResponse> {
return this.post('/api/v1/anomaly/check', req);
}
// ── Admin endpoints ───────────────────────────────────────────────────────
async getStats(): Promise<AgentStatsResponse> {
return this.get('/api/v1/admin/stats');
}
async getTopQuestions(limit: number): Promise<AgentTopQuestion[]> {
return this.get(`/api/v1/admin/top-questions?limit=${limit}`);
}
async getSessions(page: number, limit: number): Promise<AgentSessionsResponse> {
return this.get(`/api/v1/admin/sessions?page=${page}&limit=${limit}`);
}
async getSatisfactionMetrics(): Promise<AgentSatisfactionMetrics> {
return this.get('/api/v1/admin/satisfaction');
}
async configureModule(moduleId: string, config: Record<string, any>): Promise<void> {
await this.post(`/api/v1/admin/modules/${moduleId}/config`, config);
}
async healthCheck(): Promise<boolean> {
try {
const res = await this.fetchWithTimeout(`${this.agentUrl}/health`, {
method: 'GET',
headers: this.buildHeaders(),
});
return res.ok;
} catch {
return false;
}
}
// ── Private HTTP helpers ──────────────────────────────────────────────────
private async post(path: string, body: any): Promise<any> {
const res = await this.fetchWithTimeout(`${this.agentUrl}${path}`, {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`AI Agent returned HTTP ${res.status} for POST ${path}`);
}
return res.json();
}
private async get(path: string): Promise<any> {
const res = await this.fetchWithTimeout(`${this.agentUrl}${path}`, {
method: 'GET',
headers: this.buildHeaders(),
});
if (!res.ok) {
throw new Error(`AI Agent returned HTTP ${res.status} for GET ${path}`);
}
return res.json();
}
private async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
}
return headers;
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AiConversation } from '../../domain/entities/ai-conversation.entity';
import { IConversationRepository } from '../../domain/repositories/conversation.repository.interface';
@Injectable()
export class ConversationRepository implements IConversationRepository {
constructor(
@InjectRepository(AiConversation)
private readonly repo: Repository<AiConversation>,
) {}
async findById(id: string): Promise<AiConversation | null> {
return this.repo.findOne({ where: { id } });
}
async findBySessionId(sessionId: string): Promise<AiConversation | null> {
return this.repo.findOne({ where: { sessionId } });
}
async findByUserId(userId: string, skip: number, take: number): Promise<[AiConversation[], number]> {
return this.repo.findAndCount({
where: { userId },
skip,
take,
order: { updatedAt: 'DESC' },
});
}
async save(conversation: AiConversation): Promise<AiConversation> {
return this.repo.save(conversation);
}
async countByUserId(userId: string): Promise<number> {
return this.repo.count({ where: { userId } });
}
}

View File

@ -1,10 +1,18 @@
import { Controller, Post, Get, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Controller, Post, Get, Body, Inject, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AiChatService } from '../../../application/services/ai-chat.service';
import { AiCreditService } from '../../../application/services/ai-credit.service';
import { AiPricingService } from '../../../application/services/ai-pricing.service';
import { AiAnomalyService } from '../../../application/services/ai-anomaly.service';
import { ChatRequestDto, ChatResponseDto } from '../dto/chat.dto';
import { CreditScoreRequestDto, CreditScoreResponseDto } from '../dto/credit-score.dto';
import { PricingRequestDto, PricingResponseDto } from '../dto/pricing.dto';
import { AnomalyDetectRequestDto, AnomalyDetectResponseDto } from '../dto/anomaly.dto';
import {
AI_AGENT_CLIENT,
IAiAgentClient,
} from '../../../domain/ports/ai-agent.client.interface';
@ApiTags('AI')
@Controller('ai')
@ -14,13 +22,15 @@ export class AiController {
private readonly creditService: AiCreditService,
private readonly pricingService: AiPricingService,
private readonly anomalyService: AiAnomalyService,
@Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient,
) {}
@Post('chat')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: 'Chat with AI assistant' })
async chat(@Body() body: { userId: string; message: string; sessionId?: string }) {
@ApiResponse({ status: 200, description: 'Chat response', type: ChatResponseDto })
async chat(@Body() body: ChatRequestDto) {
return { code: 0, data: await this.chatService.chat(body) };
}
@ -28,7 +38,8 @@ export class AiController {
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: 'Get AI credit score' })
async creditScore(@Body() body: any) {
@ApiResponse({ status: 200, description: 'Credit score result', type: CreditScoreResponseDto })
async creditScore(@Body() body: CreditScoreRequestDto) {
return { code: 0, data: await this.creditService.getScore(body) };
}
@ -36,7 +47,8 @@ export class AiController {
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: 'Get AI pricing suggestion' })
async pricingSuggestion(@Body() body: any) {
@ApiResponse({ status: 200, description: 'Pricing suggestion', type: PricingResponseDto })
async pricingSuggestion(@Body() body: PricingRequestDto) {
return { code: 0, data: await this.pricingService.getSuggestion(body) };
}
@ -44,18 +56,22 @@ export class AiController {
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: 'Check for anomalous activity' })
async anomalyCheck(@Body() body: any) {
@ApiResponse({ status: 200, description: 'Anomaly check result', type: AnomalyDetectResponseDto })
async anomalyCheck(@Body() body: AnomalyDetectRequestDto) {
return { code: 0, data: await this.anomalyService.check(body) };
}
@Get('health')
@ApiOperation({ summary: 'AI service health + external agent status' })
async health() {
let agentHealthy = false;
try {
const res = await fetch(`${process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000'}/health`);
agentHealthy = res.ok;
} catch {}
return { code: 0, data: { service: 'ai-service', status: 'ok', externalAgent: agentHealthy ? 'connected' : 'unavailable' } };
const agentHealthy = await this.agentClient.healthCheck();
return {
code: 0,
data: {
service: 'ai-service',
status: 'ok',
externalAgent: agentHealthy ? 'connected' : 'unavailable',
},
};
}
}

View File

@ -0,0 +1,33 @@
import { IsString, IsNumber, IsOptional, IsObject, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AnomalyDetectRequestDto {
@ApiProperty({ description: 'User ID', example: 'user-123' })
@IsString()
userId: string;
@ApiProperty({ description: 'Transaction type', example: 'trade' })
@IsString()
transactionType: string;
@ApiProperty({ description: 'Transaction amount', example: 5000 })
@IsNumber()
@Min(0)
amount: number;
@ApiPropertyOptional({ description: 'Additional transaction metadata' })
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class AnomalyDetectResponseDto {
@ApiProperty({ description: 'Whether the transaction is anomalous' })
isAnomalous: boolean;
@ApiProperty({ description: 'Risk score (0-100)' })
riskScore: number;
@ApiProperty({ description: 'Reasons for anomaly detection', type: [String] })
reasons: string[];
}

View File

@ -0,0 +1,33 @@
import { IsString, IsOptional, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ChatRequestDto {
@ApiProperty({ description: 'User ID', example: 'user-123' })
@IsString()
userId: string;
@ApiProperty({ description: 'Chat message content', example: 'How do I redeem a coupon?' })
@IsString()
message: string;
@ApiPropertyOptional({ description: 'Existing session ID to continue conversation' })
@IsOptional()
@IsString()
sessionId?: string;
@ApiPropertyOptional({ description: 'Additional context for the AI agent' })
@IsOptional()
@IsObject()
context?: Record<string, any>;
}
export class ChatResponseDto {
@ApiProperty({ description: 'AI reply text' })
reply: string;
@ApiProperty({ description: 'Session ID for conversation continuity' })
sessionId: string;
@ApiPropertyOptional({ description: 'Suggested follow-up actions', type: [String] })
suggestions?: string[];
}

View File

@ -0,0 +1,50 @@
import { IsString, IsOptional, IsNumber, Min, Max } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreditScoreRequestDto {
@ApiProperty({ description: 'User ID', example: 'user-123' })
@IsString()
userId: string;
@ApiPropertyOptional({ description: 'Issuer ID for issuer-specific scoring' })
@IsOptional()
@IsString()
issuerId?: string;
@ApiProperty({ description: 'Coupon redemption rate (0-1)', example: 0.75 })
@IsNumber()
@Min(0)
@Max(1)
redemptionRate: number;
@ApiProperty({ description: 'Coupon breakage rate (0-1)', example: 0.15 })
@IsNumber()
@Min(0)
@Max(1)
breakageRate: number;
@ApiProperty({ description: 'Account tenure in days', example: 365 })
@IsNumber()
@Min(0)
tenureDays: number;
@ApiProperty({ description: 'Customer satisfaction score (0-100)', example: 85 })
@IsNumber()
@Min(0)
@Max(100)
satisfactionScore: number;
}
export class CreditScoreResponseDto {
@ApiProperty({ description: 'Composite credit score (0-100)' })
score: number;
@ApiProperty({ description: 'Credit level (A/B/C/D/F)', enum: ['A', 'B', 'C', 'D', 'F'] })
level: string;
@ApiProperty({ description: 'Score factor breakdown' })
factors: Record<string, number>;
@ApiPropertyOptional({ description: 'Improvement recommendations', type: [String] })
recommendations?: string[];
}

View File

@ -0,0 +1,46 @@
import { IsString, IsNumber, Min, Max } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class PricingRequestDto {
@ApiProperty({ description: 'Coupon ID', example: 'coupon-456' })
@IsString()
couponId: string;
@ApiProperty({ description: 'Coupon face value', example: 100 })
@IsNumber()
@Min(0)
faceValue: number;
@ApiProperty({ description: 'Days remaining to expiry', example: 90 })
@IsNumber()
@Min(0)
daysToExpiry: number;
@ApiProperty({ description: 'Total validity period in days', example: 365 })
@IsNumber()
@Min(0)
totalDays: number;
@ApiProperty({ description: 'Historical redemption rate (0-1)', example: 0.8 })
@IsNumber()
@Min(0)
@Max(1)
redemptionRate: number;
@ApiProperty({ description: 'Liquidity premium discount (0-1)', example: 0.05 })
@IsNumber()
@Min(0)
@Max(1)
liquidityPremium: number;
}
export class PricingResponseDto {
@ApiProperty({ description: 'Suggested trading price' })
suggestedPrice: number;
@ApiProperty({ description: 'Confidence level (0-1)' })
confidence: number;
@ApiProperty({ description: 'Pricing factor breakdown' })
factors: Record<string, number>;
}

View File

@ -2,16 +2,23 @@ package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
pgdriver "gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"github.com/genex/chain-indexer/internal/indexer"
appservice "github.com/genex/chain-indexer/internal/application/service"
"github.com/genex/chain-indexer/internal/infrastructure/kafka"
"github.com/genex/chain-indexer/internal/infrastructure/postgres"
"github.com/genex/chain-indexer/internal/interface/http/handler"
"github.com/genex/chain-indexer/internal/interface/http/middleware"
)
@ -25,29 +32,42 @@ func main() {
port = "3009"
}
idx := indexer.NewIndexer(logger)
idx.Start()
// ── Infrastructure layer ────────────────────────────────────────────
db := mustInitDB(logger)
blockRepo := postgres.NewPostgresBlockRepository(db)
txRepo := postgres.NewPostgresTransactionRepository(db)
eventPublisher := mustInitKafka(logger)
defer eventPublisher.Close()
// ── Application layer ───────────────────────────────────────────────
indexerSvc := appservice.NewIndexerService(logger, blockRepo, txRepo, eventPublisher)
indexerSvc.Start()
// ── Interface layer (HTTP) ──────────────────────────────────────────
r := gin.New()
r.Use(gin.Recovery())
// Health checks
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok", "service": "chain-indexer", "lastHeight": idx.GetLastHeight()})
c.JSON(200, gin.H{"status": "ok", "service": "chain-indexer", "lastHeight": indexerSvc.GetLastHeight()})
})
r.GET("/health/ready", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ready"}) })
r.GET("/health/live", func(c *gin.Context) { c.JSON(200, gin.H{"status": "alive"}) })
// Public API routes
api := r.Group("/api/v1/chain")
api.GET("/blocks", func(c *gin.Context) {
blocks := idx.GetRecentBlocks(20)
c.JSON(200, gin.H{"code": 0, "data": gin.H{"blocks": blocks, "lastHeight": idx.GetLastHeight()}})
blocks := indexerSvc.GetRecentBlocks(20)
c.JSON(200, gin.H{"code": 0, "data": gin.H{"blocks": blocks, "lastHeight": indexerSvc.GetLastHeight()}})
})
api.GET("/status", func(c *gin.Context) {
c.JSON(200, gin.H{"code": 0, "data": gin.H{"lastHeight": idx.GetLastHeight(), "syncing": true}})
c.JSON(200, gin.H{"code": 0, "data": gin.H{"lastHeight": indexerSvc.GetLastHeight(), "syncing": true}})
})
// Admin routes (require JWT + admin role)
adminChainHandler := handler.NewAdminChainHandler(idx)
adminChainHandler := handler.NewAdminChainHandler(indexerSvc)
admin := r.Group("/api/v1/admin/chain")
admin.Use(middleware.JWTAuth(), middleware.RequireAdmin())
{
@ -70,9 +90,55 @@ func main() {
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
idx.Stop()
indexerSvc.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(ctx)
logger.Info("Chain Indexer stopped")
}
func mustInitDB(logger *zap.Logger) *gorm.DB {
host := getEnv("DB_HOST", "localhost")
dbPort := getEnv("DB_PORT", "5432")
user := getEnv("DB_USERNAME", "genex")
pass := getEnv("DB_PASSWORD", "genex_dev_password")
name := getEnv("DB_NAME", "genex")
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, dbPort, user, pass, name)
db, err := gorm.Open(pgdriver.Open(dsn), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
})
if err != nil {
logger.Fatal("Failed to connect to PostgreSQL", zap.Error(err))
}
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(20)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
logger.Info("PostgreSQL connected", zap.String("host", host), zap.String("db", name))
return db
}
func mustInitKafka(logger *zap.Logger) *kafka.KafkaEventPublisher {
brokersEnv := getEnv("KAFKA_BROKERS", "localhost:9092")
brokers := strings.Split(brokersEnv, ",")
publisher, err := kafka.NewKafkaEventPublisher(brokers)
if err != nil {
logger.Fatal("Failed to connect to Kafka", zap.Error(err))
}
logger.Info("Kafka producer connected", zap.Strings("brokers", brokers))
return publisher
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@ -3,9 +3,60 @@ module github.com/genex/chain-indexer
go 1.22
require (
github.com/IBM/sarama v1.43.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/segmentio/kafka-go v0.4.47
github.com/jackc/pgx/v5 v5.5.1
go.uber.org/zap v1.27.0
gorm.io/driver/postgres v1.5.7
gorm.io/gorm v1.25.10
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/eapache/go-resiliency v1.6.0 // indirect
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect
github.com/eapache/queue v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
github.com/rogpeppe/go-internal v1.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -0,0 +1,194 @@
github.com/IBM/sarama v1.43.0 h1:YFFDn8mMI2QL0wOrG0J2sFoVIAFl7hS9JQi2YZsXtJc=
github.com/IBM/sarama v1.43.0/go.mod h1:zlE6HEbC/SMQ9mhEYaF7nNLYOUyrs0obySKCckWP9BM=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eapache/go-resiliency v1.6.0 h1:CqGDTLtpwuWKn6Nj3uNUdflaq+/kIPsg0gfNzHton30=
github.com/eapache/go-resiliency v1.6.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho=
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws=
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0=
github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,144 @@
package service
import (
"context"
"fmt"
"sync"
"time"
"go.uber.org/zap"
"github.com/genex/chain-indexer/internal/domain/entity"
"github.com/genex/chain-indexer/internal/domain/event"
"github.com/genex/chain-indexer/internal/domain/repository"
)
// IndexerService is the application service that orchestrates block indexing.
// It depends on domain repository and event publisher interfaces — not concrete
// implementations — following the Dependency Inversion Principle.
type IndexerService struct {
logger *zap.Logger
blockRepo repository.BlockRepository
txRepo repository.TransactionRepository
publisher event.EventPublisher
mu sync.RWMutex
isRunning bool
stopCh chan struct{}
}
// NewIndexerService creates a new IndexerService with all dependencies injected.
func NewIndexerService(
logger *zap.Logger,
blockRepo repository.BlockRepository,
txRepo repository.TransactionRepository,
publisher event.EventPublisher,
) *IndexerService {
return &IndexerService{
logger: logger,
blockRepo: blockRepo,
txRepo: txRepo,
publisher: publisher,
stopCh: make(chan struct{}),
}
}
// Start begins the mock block indexing loop.
func (s *IndexerService) Start() {
s.mu.Lock()
s.isRunning = true
s.mu.Unlock()
s.logger.Info("Chain indexer started (mock mode)")
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
if err := s.indexNextBlock(); err != nil {
s.logger.Error("Failed to index block", zap.Error(err))
}
}
}
}()
}
// Stop halts the indexing loop.
func (s *IndexerService) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.isRunning {
s.isRunning = false
close(s.stopCh)
s.logger.Info("Chain indexer stopped")
}
}
// GetLastHeight returns the height of the most recently indexed block.
func (s *IndexerService) GetLastHeight() int64 {
ctx := context.Background()
latest, err := s.blockRepo.FindLatest(ctx)
if err != nil || latest == nil {
return 0
}
return latest.Height
}
// GetRecentBlocks returns the N most recently indexed blocks.
func (s *IndexerService) GetRecentBlocks(limit int) []entity.Block {
ctx := context.Background()
blocks, err := s.blockRepo.FindRecent(ctx, limit)
if err != nil {
s.logger.Error("Failed to get recent blocks", zap.Error(err))
return nil
}
// Convert []*entity.Block to []entity.Block for backward compatibility
result := make([]entity.Block, len(blocks))
for i, b := range blocks {
result[i] = *b
}
return result
}
// indexNextBlock creates and indexes a mock block, persists it through the
// repository, and publishes a domain event.
func (s *IndexerService) indexNextBlock() error {
ctx := context.Background()
// Determine next height
lastHeight := s.GetLastHeight()
nextHeight := lastHeight + 1
// Create block via domain factory
block, err := entity.NewBlock(
nextHeight,
fmt.Sprintf("0x%064d", nextHeight),
time.Now(),
0,
)
if err != nil {
return fmt.Errorf("failed to create block entity: %w", err)
}
// Persist through repository
if err := s.blockRepo.SaveBlock(ctx, block); err != nil {
return fmt.Errorf("failed to save block: %w", err)
}
// Publish domain event
evt := event.NewBlockIndexedEvent(block.Height, block.Hash, block.TxCount, block.Timestamp)
if err := s.publisher.Publish(evt); err != nil {
s.logger.Warn("Failed to publish block indexed event", zap.Error(err))
// Non-fatal: don't fail the indexing operation
}
s.logger.Debug("Indexed mock block", zap.Int64("height", nextHeight))
return nil
}

View File

@ -1,7 +1,11 @@
package entity
import "time"
import (
"fmt"
"time"
)
// Block is the aggregate root representing an indexed blockchain block.
type Block struct {
Height int64 `json:"height"`
Hash string `json:"hash"`
@ -9,6 +13,50 @@ type Block struct {
TxCount int `json:"txCount"`
}
// NewBlock is the factory method that creates a validated Block entity.
func NewBlock(height int64, hash string, timestamp time.Time, txCount int) (*Block, error) {
if height < 0 {
return nil, fmt.Errorf("block height must be non-negative, got %d", height)
}
if hash == "" {
return nil, fmt.Errorf("block hash must not be empty")
}
if txCount < 0 {
return nil, fmt.Errorf("transaction count must be non-negative, got %d", txCount)
}
return &Block{
Height: height,
Hash: hash,
Timestamp: timestamp,
TxCount: txCount,
}, nil
}
// Validate checks all invariants on an existing Block.
func (b *Block) Validate() error {
if b.Height < 0 {
return fmt.Errorf("block height must be non-negative")
}
if b.Hash == "" {
return fmt.Errorf("block hash must not be empty")
}
if b.TxCount < 0 {
return fmt.Errorf("transaction count must be non-negative")
}
return nil
}
// IsGenesis reports whether this is the genesis block (height 0).
func (b *Block) IsGenesis() bool {
return b.Height == 0
}
// HasTransactions reports whether this block contains any transactions.
func (b *Block) HasTransactions() bool {
return b.TxCount > 0
}
// ChainTransaction represents an indexed on-chain transaction.
type ChainTransaction struct {
Hash string `json:"hash"`
BlockHeight int64 `json:"blockHeight"`
@ -18,3 +66,49 @@ type ChainTransaction struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
}
// NewChainTransaction is the factory method that creates a validated ChainTransaction.
func NewChainTransaction(hash string, blockHeight int64, from, to, amount, status string, timestamp time.Time) (*ChainTransaction, error) {
if hash == "" {
return nil, fmt.Errorf("transaction hash must not be empty")
}
if blockHeight < 0 {
return nil, fmt.Errorf("block height must be non-negative")
}
if from == "" {
return nil, fmt.Errorf("from address must not be empty")
}
return &ChainTransaction{
Hash: hash,
BlockHeight: blockHeight,
From: from,
To: to,
Amount: amount,
Status: status,
Timestamp: timestamp,
}, nil
}
// Validate checks all invariants on an existing ChainTransaction.
func (tx *ChainTransaction) Validate() error {
if tx.Hash == "" {
return fmt.Errorf("transaction hash must not be empty")
}
if tx.BlockHeight < 0 {
return fmt.Errorf("block height must be non-negative")
}
if tx.From == "" {
return fmt.Errorf("from address must not be empty")
}
return nil
}
// IsConfirmed reports whether the transaction has a "confirmed" status.
func (tx *ChainTransaction) IsConfirmed() bool {
return tx.Status == "confirmed"
}
// IsPending reports whether the transaction has a "pending" status.
func (tx *ChainTransaction) IsPending() bool {
return tx.Status == "pending"
}

View File

@ -0,0 +1,82 @@
package event
import "time"
// DomainEvent is the base interface for all domain events.
type DomainEvent interface {
// EventName returns the fully-qualified event name.
EventName() string
// OccurredAt returns the timestamp when the event was created.
OccurredAt() time.Time
}
// BlockIndexedEvent is published when a new block has been successfully indexed.
type BlockIndexedEvent struct {
Height int64 `json:"height"`
Hash string `json:"hash"`
TxCount int `json:"txCount"`
Timestamp time.Time `json:"timestamp"`
occurredAt time.Time
}
// NewBlockIndexedEvent creates a new BlockIndexedEvent.
func NewBlockIndexedEvent(height int64, hash string, txCount int, blockTime time.Time) *BlockIndexedEvent {
return &BlockIndexedEvent{
Height: height,
Hash: hash,
TxCount: txCount,
Timestamp: blockTime,
occurredAt: time.Now(),
}
}
// EventName returns the event name.
func (e *BlockIndexedEvent) EventName() string {
return "chain.block.indexed"
}
// OccurredAt returns when the event was created.
func (e *BlockIndexedEvent) OccurredAt() time.Time {
return e.occurredAt
}
// TransactionIndexedEvent is published when a transaction has been indexed.
type TransactionIndexedEvent struct {
TxHash string `json:"txHash"`
BlockHeight int64 `json:"blockHeight"`
From string `json:"from"`
To string `json:"to"`
Amount string `json:"amount"`
Status string `json:"status"`
occurredAt time.Time
}
// NewTransactionIndexedEvent creates a new TransactionIndexedEvent.
func NewTransactionIndexedEvent(txHash string, blockHeight int64, from, to, amount, status string) *TransactionIndexedEvent {
return &TransactionIndexedEvent{
TxHash: txHash,
BlockHeight: blockHeight,
From: from,
To: to,
Amount: amount,
Status: status,
occurredAt: time.Now(),
}
}
// EventName returns the event name.
func (e *TransactionIndexedEvent) EventName() string {
return "chain.transaction.indexed"
}
// OccurredAt returns when the event was created.
func (e *TransactionIndexedEvent) OccurredAt() time.Time {
return e.occurredAt
}
// EventPublisher defines the contract for publishing domain events.
// Infrastructure layer (e.g. Kafka) provides the concrete implementation.
type EventPublisher interface {
// Publish sends a domain event to the event bus.
Publish(event DomainEvent) error
}

View File

@ -0,0 +1,27 @@
package repository
import (
"context"
"github.com/genex/chain-indexer/internal/domain/entity"
)
// BlockRepository defines the contract for block persistence.
// Infrastructure layer must provide the concrete implementation.
type BlockRepository interface {
// SaveBlock persists a block. If a block at the same height exists, it is overwritten.
SaveBlock(ctx context.Context, block *entity.Block) error
// FindByHeight retrieves a block by its height. Returns nil if not found.
FindByHeight(ctx context.Context, height int64) (*entity.Block, error)
// FindLatest returns the most recently indexed block. Returns nil if no blocks exist.
FindLatest(ctx context.Context) (*entity.Block, error)
// FindRange returns all blocks in the height range [fromHeight, toHeight] inclusive,
// ordered by height ascending.
FindRange(ctx context.Context, fromHeight, toHeight int64) ([]*entity.Block, error)
// FindRecent returns the most recent N blocks, ordered by height descending.
FindRecent(ctx context.Context, limit int) ([]*entity.Block, error)
}

View File

@ -0,0 +1,23 @@
package repository
import (
"context"
"github.com/genex/chain-indexer/internal/domain/entity"
)
// TransactionRepository defines the contract for on-chain transaction persistence.
// Infrastructure layer must provide the concrete implementation.
type TransactionRepository interface {
// Save persists a chain transaction.
Save(ctx context.Context, tx *entity.ChainTransaction) error
// SaveBatch persists multiple transactions in a single operation.
SaveBatch(ctx context.Context, txs []*entity.ChainTransaction) error
// FindByHash retrieves a transaction by its hash. Returns nil if not found.
FindByHash(ctx context.Context, hash string) (*entity.ChainTransaction, error)
// FindByBlock returns all transactions belonging to a given block height.
FindByBlock(ctx context.Context, blockHeight int64) ([]*entity.ChainTransaction, error)
}

View File

@ -0,0 +1,42 @@
package vo
import "fmt"
// BlockHeight is a value object representing a blockchain block height (number).
// Block heights must be non-negative.
type BlockHeight struct {
value int64
}
// NewBlockHeight creates a validated BlockHeight value object.
func NewBlockHeight(height int64) (BlockHeight, error) {
if height < 0 {
return BlockHeight{}, fmt.Errorf("block height must be non-negative, got %d", height)
}
return BlockHeight{value: height}, nil
}
// Value returns the underlying int64 value.
func (h BlockHeight) Value() int64 {
return h.value
}
// IsGenesis reports whether this is the genesis block (height 0).
func (h BlockHeight) IsGenesis() bool {
return h.value == 0
}
// Next returns the next block height.
func (h BlockHeight) Next() BlockHeight {
return BlockHeight{value: h.value + 1}
}
// String returns the string representation.
func (h BlockHeight) String() string {
return fmt.Sprintf("%d", h.value)
}
// IsValid reports whether the block height is valid (non-negative).
func (h BlockHeight) IsValid() bool {
return h.value >= 0
}

View File

@ -0,0 +1,43 @@
package vo
import (
"fmt"
"regexp"
"strings"
)
// TransactionHash is a value object representing an on-chain transaction hash.
type TransactionHash struct {
value string
}
// txHashPattern matches a hex-encoded transaction hash (0x-prefixed, 64 hex chars for 32 bytes).
var txHashPattern = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`)
// NewTransactionHash creates a validated TransactionHash value object.
func NewTransactionHash(raw string) (TransactionHash, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return TransactionHash{}, fmt.Errorf("transaction hash must not be empty")
}
if !txHashPattern.MatchString(trimmed) {
return TransactionHash{}, fmt.Errorf("invalid transaction hash format: %s", trimmed)
}
// Normalise to lowercase
return TransactionHash{value: strings.ToLower(trimmed)}, nil
}
// Value returns the string value of the transaction hash.
func (h TransactionHash) Value() string {
return h.value
}
// String returns the string representation.
func (h TransactionHash) String() string {
return h.value
}
// IsValid reports whether the hash passes format validation.
func (h TransactionHash) IsValid() bool {
return txHashPattern.MatchString(h.value)
}

View File

@ -1,81 +0,0 @@
package indexer
import (
"fmt"
"sync"
"time"
"go.uber.org/zap"
"github.com/genex/chain-indexer/internal/domain/entity"
)
type Indexer struct {
logger *zap.Logger
lastHeight int64
blocks []entity.Block
transactions []entity.ChainTransaction
mu sync.RWMutex
isRunning bool
}
func NewIndexer(logger *zap.Logger) *Indexer {
return &Indexer{logger: logger}
}
func (idx *Indexer) Start() {
idx.isRunning = true
idx.logger.Info("Chain indexer started (mock mode)")
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for idx.isRunning {
select {
case <-ticker.C:
idx.mockIndexBlock()
}
}
}()
}
func (idx *Indexer) Stop() {
idx.isRunning = false
idx.logger.Info("Chain indexer stopped")
}
func (idx *Indexer) GetLastHeight() int64 {
idx.mu.RLock()
defer idx.mu.RUnlock()
return idx.lastHeight
}
func (idx *Indexer) GetRecentBlocks(limit int) []entity.Block {
idx.mu.RLock()
defer idx.mu.RUnlock()
start := len(idx.blocks) - limit
if start < 0 {
start = 0
}
result := make([]entity.Block, len(idx.blocks[start:]))
copy(result, idx.blocks[start:])
return result
}
func (idx *Indexer) mockIndexBlock() {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.lastHeight++
block := entity.Block{
Height: idx.lastHeight,
Hash: fmt.Sprintf("0x%064d", idx.lastHeight),
Timestamp: time.Now(),
TxCount: 0,
}
idx.blocks = append(idx.blocks, block)
// Keep only last 1000 blocks in memory
if len(idx.blocks) > 1000 {
idx.blocks = idx.blocks[len(idx.blocks)-1000:]
}
idx.logger.Debug("Indexed mock block", zap.Int64("height", idx.lastHeight))
}

View File

@ -0,0 +1,73 @@
package kafka
import (
"encoding/json"
"fmt"
"strings"
"github.com/IBM/sarama"
"github.com/genex/chain-indexer/internal/domain/event"
)
// Compile-time check: KafkaEventPublisher implements event.EventPublisher.
var _ event.EventPublisher = (*KafkaEventPublisher)(nil)
// KafkaEventPublisher implements event.EventPublisher by publishing domain events
// to Kafka using the IBM/sarama client.
type KafkaEventPublisher struct {
producer sarama.SyncProducer
}
// NewKafkaEventPublisher creates a new Kafka event publisher connected to the given brokers.
func NewKafkaEventPublisher(brokers []string) (*KafkaEventPublisher, error) {
config := sarama.NewConfig()
config.Producer.RequiredAcks = sarama.WaitForAll
config.Producer.Retry.Max = 3
config.Producer.Return.Successes = true
producer, err := sarama.NewSyncProducer(brokers, config)
if err != nil {
return nil, fmt.Errorf("failed to create Kafka producer: %w", err)
}
return &KafkaEventPublisher{producer: producer}, nil
}
// Publish serializes a domain event to JSON and publishes it to the appropriate Kafka topic.
func (p *KafkaEventPublisher) Publish(evt event.DomainEvent) error {
payload, err := json.Marshal(evt)
if err != nil {
return fmt.Errorf("failed to marshal event %s: %w", evt.EventName(), err)
}
topic := resolveTopic(evt.EventName())
msg := &sarama.ProducerMessage{
Topic: topic,
Key: sarama.StringEncoder(evt.EventName()),
Value: sarama.ByteEncoder(payload),
}
_, _, err = p.producer.SendMessage(msg)
if err != nil {
return fmt.Errorf("failed to publish event %s to topic %s: %w", evt.EventName(), topic, err)
}
return nil
}
// Close shuts down the Kafka producer gracefully.
func (p *KafkaEventPublisher) Close() error {
if p.producer != nil {
return p.producer.Close()
}
return nil
}
// resolveTopic maps event names to Kafka topics.
func resolveTopic(eventName string) string {
if strings.HasPrefix(eventName, "chain.block.") {
return "chain.blocks"
}
return "chain.transactions"
}

View File

@ -0,0 +1,114 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/genex/chain-indexer/internal/domain/entity"
"github.com/genex/chain-indexer/internal/domain/repository"
"gorm.io/gorm"
)
// Compile-time check: PostgresBlockRepository implements repository.BlockRepository.
var _ repository.BlockRepository = (*PostgresBlockRepository)(nil)
// blockModel is the GORM persistence model for the blocks table.
type blockModel struct {
Height int64 `gorm:"column:height;primaryKey"`
Hash string `gorm:"column:hash;not null;uniqueIndex"`
TxCount int `gorm:"column:tx_count;not null;default:0"`
IndexedAt time.Time `gorm:"column:indexed_at;autoCreateTime"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
}
func (blockModel) TableName() string { return "blocks" }
func (m *blockModel) toEntity() *entity.Block {
return &entity.Block{
Height: m.Height,
Hash: m.Hash,
Timestamp: m.IndexedAt,
TxCount: m.TxCount,
}
}
func blockFromEntity(e *entity.Block) *blockModel {
return &blockModel{
Height: e.Height,
Hash: e.Hash,
TxCount: e.TxCount,
IndexedAt: e.Timestamp,
}
}
// PostgresBlockRepository is the GORM-backed implementation of repository.BlockRepository.
type PostgresBlockRepository struct {
db *gorm.DB
}
// NewPostgresBlockRepository creates a new repository backed by PostgreSQL via GORM.
func NewPostgresBlockRepository(db *gorm.DB) *PostgresBlockRepository {
return &PostgresBlockRepository{db: db}
}
func (r *PostgresBlockRepository) SaveBlock(ctx context.Context, block *entity.Block) error {
if block == nil {
return fmt.Errorf("block must not be nil")
}
model := blockFromEntity(block)
return r.db.WithContext(ctx).Save(model).Error
}
func (r *PostgresBlockRepository) FindByHeight(ctx context.Context, height int64) (*entity.Block, error) {
var model blockModel
err := r.db.WithContext(ctx).Where("height = ?", height).First(&model).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return model.toEntity(), nil
}
func (r *PostgresBlockRepository) FindLatest(ctx context.Context) (*entity.Block, error) {
var model blockModel
err := r.db.WithContext(ctx).Order("height DESC").First(&model).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return model.toEntity(), nil
}
func (r *PostgresBlockRepository) FindRange(ctx context.Context, fromHeight, toHeight int64) ([]*entity.Block, error) {
var models []blockModel
err := r.db.WithContext(ctx).
Where("height >= ? AND height <= ?", fromHeight, toHeight).
Order("height ASC").
Find(&models).Error
if err != nil {
return nil, err
}
result := make([]*entity.Block, len(models))
for i := range models {
result[i] = models[i].toEntity()
}
return result, nil
}
func (r *PostgresBlockRepository) FindRecent(ctx context.Context, limit int) ([]*entity.Block, error) {
var models []blockModel
err := r.db.WithContext(ctx).Order("height DESC").Limit(limit).Find(&models).Error
if err != nil {
return nil, err
}
result := make([]*entity.Block, len(models))
for i := range models {
result[i] = models[i].toEntity()
}
return result, nil
}

View File

@ -0,0 +1,105 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/genex/chain-indexer/internal/domain/entity"
"github.com/genex/chain-indexer/internal/domain/repository"
"gorm.io/gorm"
)
// Compile-time check: PostgresTransactionRepository implements repository.TransactionRepository.
var _ repository.TransactionRepository = (*PostgresTransactionRepository)(nil)
// chainTxModel is the GORM persistence model for the chain_transactions table.
type chainTxModel struct {
Hash string `gorm:"column:hash;primaryKey"`
BlockHeight int64 `gorm:"column:block_height;not null"`
FromAddr string `gorm:"column:from_addr;not null"`
ToAddr string `gorm:"column:to_addr;not null"`
Amount string `gorm:"column:amount;not null;default:0"`
Status string `gorm:"column:status;not null;default:confirmed"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
}
func (chainTxModel) TableName() string { return "chain_transactions" }
func (m *chainTxModel) toEntity() *entity.ChainTransaction {
return &entity.ChainTransaction{
Hash: m.Hash,
BlockHeight: m.BlockHeight,
From: m.FromAddr,
To: m.ToAddr,
Amount: m.Amount,
Status: m.Status,
Timestamp: m.CreatedAt,
}
}
func chainTxFromEntity(e *entity.ChainTransaction) *chainTxModel {
return &chainTxModel{
Hash: e.Hash,
BlockHeight: e.BlockHeight,
FromAddr: e.From,
ToAddr: e.To,
Amount: e.Amount,
Status: e.Status,
}
}
// PostgresTransactionRepository is the GORM-backed implementation of
// repository.TransactionRepository.
type PostgresTransactionRepository struct {
db *gorm.DB
}
// NewPostgresTransactionRepository creates a new repository backed by PostgreSQL via GORM.
func NewPostgresTransactionRepository(db *gorm.DB) *PostgresTransactionRepository {
return &PostgresTransactionRepository{db: db}
}
func (r *PostgresTransactionRepository) Save(ctx context.Context, tx *entity.ChainTransaction) error {
if tx == nil {
return fmt.Errorf("transaction must not be nil")
}
model := chainTxFromEntity(tx)
return r.db.WithContext(ctx).Save(model).Error
}
func (r *PostgresTransactionRepository) SaveBatch(ctx context.Context, txs []*entity.ChainTransaction) error {
if len(txs) == 0 {
return nil
}
models := make([]chainTxModel, len(txs))
for i, tx := range txs {
models[i] = *chainTxFromEntity(tx)
}
return r.db.WithContext(ctx).Save(&models).Error
}
func (r *PostgresTransactionRepository) FindByHash(ctx context.Context, hash string) (*entity.ChainTransaction, error) {
var model chainTxModel
err := r.db.WithContext(ctx).Where("hash = ?", hash).First(&model).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return model.toEntity(), nil
}
func (r *PostgresTransactionRepository) FindByBlock(ctx context.Context, blockHeight int64) ([]*entity.ChainTransaction, error) {
var models []chainTxModel
err := r.db.WithContext(ctx).Where("block_height = ?", blockHeight).Find(&models).Error
if err != nil {
return nil, err
}
result := make([]*entity.ChainTransaction, len(models))
for i := range models {
result[i] = models[i].toEntity()
}
return result, nil
}

View File

@ -8,17 +8,18 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/genex/chain-indexer/internal/indexer"
"github.com/genex/chain-indexer/internal/application/service"
)
// AdminChainHandler handles admin chain monitoring endpoints.
// It depends on the application service layer, not on infrastructure directly.
type AdminChainHandler struct {
idx *indexer.Indexer
indexerSvc *service.IndexerService
}
// NewAdminChainHandler creates a new AdminChainHandler.
func NewAdminChainHandler(idx *indexer.Indexer) *AdminChainHandler {
return &AdminChainHandler{idx: idx}
func NewAdminChainHandler(indexerSvc *service.IndexerService) *AdminChainHandler {
return &AdminChainHandler{indexerSvc: indexerSvc}
}
// GetContracts returns smart contract deployment status.
@ -31,7 +32,7 @@ func (h *AdminChainHandler) GetContracts(c *gin.Context) {
"type": "ERC-1155",
"status": "deployed",
"deployedAt": time.Now().AddDate(0, -2, 0).UTC().Format(time.RFC3339),
"blockHeight": h.idx.GetLastHeight() - 5000,
"blockHeight": h.indexerSvc.GetLastHeight() - 5000,
"txCount": 12580,
"version": "1.0.0",
},
@ -41,7 +42,7 @@ func (h *AdminChainHandler) GetContracts(c *gin.Context) {
"type": "Custom",
"status": "deployed",
"deployedAt": time.Now().AddDate(0, -2, 0).UTC().Format(time.RFC3339),
"blockHeight": h.idx.GetLastHeight() - 4998,
"blockHeight": h.indexerSvc.GetLastHeight() - 4998,
"txCount": 8920,
"version": "1.0.0",
},
@ -51,7 +52,7 @@ func (h *AdminChainHandler) GetContracts(c *gin.Context) {
"type": "Proxy",
"status": "deployed",
"deployedAt": time.Now().AddDate(0, -1, -15).UTC().Format(time.RFC3339),
"blockHeight": h.idx.GetLastHeight() - 3200,
"blockHeight": h.indexerSvc.GetLastHeight() - 3200,
"txCount": 15340,
"version": "1.1.0",
},
@ -61,7 +62,7 @@ func (h *AdminChainHandler) GetContracts(c *gin.Context) {
"type": "Custom",
"status": "deployed",
"deployedAt": time.Now().AddDate(0, -1, 0).UTC().Format(time.RFC3339),
"blockHeight": h.idx.GetLastHeight() - 2100,
"blockHeight": h.indexerSvc.GetLastHeight() - 2100,
"txCount": 3260,
"version": "1.0.0",
},
@ -103,7 +104,7 @@ func (h *AdminChainHandler) GetEvents(c *gin.Context) {
statuses := []string{"confirmed", "confirmed", "confirmed", "pending"}
var allEvents []gin.H
lastHeight := h.idx.GetLastHeight()
lastHeight := h.indexerSvc.GetLastHeight()
for i := 0; i < 100; i++ {
evtType := eventTypes[rng.Intn(len(eventTypes))]
@ -204,8 +205,8 @@ func (h *AdminChainHandler) GetGasMonitor(c *gin.Context) {
// GetChainStats returns chain statistics.
func (h *AdminChainHandler) GetChainStats(c *gin.Context) {
lastHeight := h.idx.GetLastHeight()
blocks := h.idx.GetRecentBlocks(100)
lastHeight := h.indexerSvc.GetLastHeight()
blocks := h.indexerSvc.GetRecentBlocks(100)
// Calculate real stats from indexed blocks
totalTx := 0

View File

@ -1,9 +1,19 @@
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { Settlement, SettlementStatus } from '../../domain/entities/settlement.entity';
import { JournalEntry, JournalType } from '../../domain/entities/journal-entry.entity';
import { Refund, RefundStatus } from '../../domain/entities/refund.entity';
import { JournalType } from '../../domain/entities/journal-entry.entity';
import { RefundStatus } from '../../domain/entities/refund.entity';
import {
SETTLEMENT_REPOSITORY,
ISettlementRepository,
} from '../../domain/repositories/settlement.repository.interface';
import {
JOURNAL_ENTRY_REPOSITORY,
IJournalEntryRepository,
} from '../../domain/repositories/journal-entry.repository.interface';
import {
REFUND_REPOSITORY,
IRefundRepository,
} from '../../domain/repositories/refund.repository.interface';
export interface FinanceSummary {
totalFeesCollected: string;
@ -28,62 +38,38 @@ export class AdminFinanceService {
private readonly logger = new Logger('AdminFinanceService');
constructor(
@InjectRepository(Settlement) private readonly settlementRepo: Repository<Settlement>,
@InjectRepository(JournalEntry) private readonly journalRepo: Repository<JournalEntry>,
@InjectRepository(Refund) private readonly refundRepo: Repository<Refund>,
@Inject(SETTLEMENT_REPOSITORY)
private readonly settlementRepo: ISettlementRepository,
@Inject(JOURNAL_ENTRY_REPOSITORY)
private readonly journalRepo: IJournalEntryRepository,
@Inject(REFUND_REPOSITORY)
private readonly refundRepo: IRefundRepository,
) {}
/**
* Aggregate platform finance overview from settlements + journal entries.
*/
async getSummary(): Promise<FinanceSummary> {
// Total fees collected from journal entries of type TRADE_FEE
const feeResult = await this.journalRepo
.createQueryBuilder('j')
.select('COALESCE(SUM(j.amount::numeric), 0)', 'total')
.where('j.entry_type = :type', { type: JournalType.TRADE_FEE })
.getRawOne();
const [totalFeesCollected, pendingStats, completedStats, refundStats, completedRefundTotal] =
await Promise.all([
this.journalRepo.getSumByType(JournalType.TRADE_FEE),
this.settlementRepo.getStatsByStatus(SettlementStatus.PENDING),
this.settlementRepo.getStatsByStatus(SettlementStatus.COMPLETED),
this.refundRepo.getRefundStats(),
this.refundRepo.getCompletedRefundTotal(),
]);
// Pending settlements
const pendingStats = await this.settlementRepo
.createQueryBuilder('s')
.select('COUNT(s.id)', 'count')
.addSelect('COALESCE(SUM(s.amount::numeric), 0)', 'total')
.where('s.status = :status', { status: SettlementStatus.PENDING })
.getRawOne();
// Completed settlements
const completedStats = await this.settlementRepo
.createQueryBuilder('s')
.select('COUNT(s.id)', 'count')
.addSelect('COALESCE(SUM(s.amount::numeric), 0)', 'total')
.where('s.status = :status', { status: SettlementStatus.COMPLETED })
.getRawOne();
// Refunds
const refundStats = await this.refundRepo
.createQueryBuilder('r')
.select('COUNT(r.id)', 'count')
.addSelect('COALESCE(SUM(r.amount::numeric), 0)', 'total')
.getRawOne();
// Pool balance = total settled - total refunds completed
const completedRefundTotal = await this.refundRepo
.createQueryBuilder('r')
.select('COALESCE(SUM(r.amount::numeric), 0)', 'total')
.where('r.status = :status', { status: RefundStatus.COMPLETED })
.getRawOne();
const poolBalance = parseFloat(completedStats?.total || '0') - parseFloat(completedRefundTotal?.total || '0');
const poolBalance =
parseFloat(completedStats.total) - parseFloat(completedRefundTotal);
return {
totalFeesCollected: feeResult?.total || '0',
pendingSettlements: parseInt(pendingStats?.count || '0', 10),
pendingSettlementAmount: pendingStats?.total || '0',
completedSettlements: parseInt(completedStats?.count || '0', 10),
completedSettlementAmount: completedStats?.total || '0',
totalRefunds: parseInt(refundStats?.count || '0', 10),
totalRefundAmount: refundStats?.total || '0',
totalFeesCollected,
pendingSettlements: pendingStats.count,
pendingSettlementAmount: pendingStats.total,
completedSettlements: completedStats.count,
completedSettlementAmount: completedStats.total,
totalRefunds: refundStats.count,
totalRefundAmount: refundStats.total,
poolBalance: String(poolBalance),
};
}
@ -92,17 +78,11 @@ export class AdminFinanceService {
* Paginated list of settlements with optional status filter.
*/
async getSettlements(page: number, limit: number, status?: string) {
const qb = this.settlementRepo.createQueryBuilder('s');
if (status) {
qb.where('s.status = :status', { status });
}
qb.orderBy('s.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await qb.getManyAndCount();
const [items, total] = await this.settlementRepo.findAndCount({
status: status as SettlementStatus | undefined,
page,
limit,
});
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
@ -111,31 +91,11 @@ export class AdminFinanceService {
* Returns the last 12 months of data.
*/
async getRevenueTrend(): Promise<RevenueTrendItem[]> {
const feeResults = await this.journalRepo
.createQueryBuilder('j')
.select("TO_CHAR(j.created_at, 'YYYY-MM')", 'month')
.addSelect('COALESCE(SUM(j.amount::numeric), 0)', 'revenue')
.where('j.entry_type = :type', { type: JournalType.TRADE_FEE })
.andWhere('j.created_at >= NOW() - INTERVAL \'12 months\'')
.groupBy("TO_CHAR(j.created_at, 'YYYY-MM')")
.orderBy('month', 'ASC')
.getRawMany();
const settlementCounts = await this.settlementRepo
.createQueryBuilder('s')
.select("TO_CHAR(s.created_at, 'YYYY-MM')", 'month')
.addSelect('COUNT(s.id)', 'settlements')
.where('s.created_at >= NOW() - INTERVAL \'12 months\'')
.groupBy("TO_CHAR(s.created_at, 'YYYY-MM')")
.getRawMany();
const refundCounts = await this.refundRepo
.createQueryBuilder('r')
.select("TO_CHAR(r.created_at, 'YYYY-MM')", 'month')
.addSelect('COUNT(r.id)', 'refunds')
.where('r.created_at >= NOW() - INTERVAL \'12 months\'')
.groupBy("TO_CHAR(r.created_at, 'YYYY-MM')")
.getRawMany();
const [feeResults, settlementCounts, refundCounts] = await Promise.all([
this.journalRepo.getMonthlyRevenueByType(JournalType.TRADE_FEE),
this.settlementRepo.getMonthlySettlementCounts(),
this.refundRepo.getMonthlyRefundCounts(),
]);
// Merge results by month
const monthMap = new Map<string, RevenueTrendItem>();
@ -152,34 +112,51 @@ export class AdminFinanceService {
for (const row of settlementCounts) {
const existing = monthMap.get(row.month);
if (existing) {
existing.settlements = parseInt(row.settlements, 10);
existing.settlements = row.settlements;
} else {
monthMap.set(row.month, { month: row.month, revenue: '0', settlements: parseInt(row.settlements, 10), refunds: 0 });
monthMap.set(row.month, {
month: row.month,
revenue: '0',
settlements: row.settlements,
refunds: 0,
});
}
}
for (const row of refundCounts) {
const existing = monthMap.get(row.month);
if (existing) {
existing.refunds = parseInt(row.refunds, 10);
existing.refunds = row.refunds;
} else {
monthMap.set(row.month, { month: row.month, revenue: '0', settlements: 0, refunds: parseInt(row.refunds, 10) });
monthMap.set(row.month, {
month: row.month,
revenue: '0',
settlements: 0,
refunds: row.refunds,
});
}
}
return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
return Array.from(monthMap.values()).sort((a, b) =>
a.month.localeCompare(b.month),
);
}
/**
* Process a pending settlement: move status to PROCESSING then COMPLETED.
*/
async processSettlement(id: string): Promise<Settlement> {
const settlement = await this.settlementRepo.findOne({ where: { id } });
const settlement = await this.settlementRepo.findById(id);
if (!settlement) {
throw new NotFoundException(`Settlement ${id} not found`);
}
if (settlement.status !== SettlementStatus.PENDING && settlement.status !== SettlementStatus.PROCESSING) {
throw new BadRequestException(`Settlement ${id} cannot be processed (current status: ${settlement.status})`);
if (
settlement.status !== SettlementStatus.PENDING &&
settlement.status !== SettlementStatus.PROCESSING
) {
throw new BadRequestException(
`Settlement ${id} cannot be processed (current status: ${settlement.status})`,
);
}
if (settlement.status === SettlementStatus.PENDING) {
@ -200,12 +177,14 @@ export class AdminFinanceService {
* Cancel a pending settlement.
*/
async cancelSettlement(id: string): Promise<Settlement> {
const settlement = await this.settlementRepo.findOne({ where: { id } });
const settlement = await this.settlementRepo.findById(id);
if (!settlement) {
throw new NotFoundException(`Settlement ${id} not found`);
}
if (settlement.status !== SettlementStatus.PENDING) {
throw new BadRequestException(`Only pending settlements can be cancelled (current status: ${settlement.status})`);
throw new BadRequestException(
`Only pending settlements can be cancelled (current status: ${settlement.status})`,
);
}
settlement.status = SettlementStatus.FAILED;
@ -219,17 +198,11 @@ export class AdminFinanceService {
* List consumer refund records with optional status filter.
*/
async getConsumerRefunds(page: number, limit: number, status?: string) {
const qb = this.refundRepo.createQueryBuilder('r');
if (status) {
qb.where('r.status = :status', { status });
}
qb.orderBy('r.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await qb.getManyAndCount();
const [items, total] = await this.refundRepo.findAndCount({
status: status as RefundStatus | undefined,
page,
limit,
});
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
}

View File

@ -1,36 +1,28 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
import { Report, ReportType, ReportStatus } from '../../domain/entities/report.entity';
export interface GenerateReportDto {
type: ReportType;
period?: string;
}
import {
REPORT_REPOSITORY,
IReportRepository,
} from '../../domain/repositories/report.repository.interface';
@Injectable()
export class AdminReportsService {
private readonly logger = new Logger('AdminReportsService');
constructor(
@InjectRepository(Report) private readonly reportRepo: Repository<Report>,
@Inject(REPORT_REPOSITORY)
private readonly reportRepo: IReportRepository,
) {}
/**
* List all reports with pagination.
*/
async listReports(page: number, limit: number, type?: string) {
const qb = this.reportRepo.createQueryBuilder('r');
if (type) {
qb.where('r.type = :type', { type });
}
qb.orderBy('r.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await qb.getManyAndCount();
const [items, total] = await this.reportRepo.findAndCount({
type: type as ReportType | undefined,
page,
limit,
});
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
@ -38,7 +30,10 @@ export class AdminReportsService {
* Trigger report generation.
* Creates a report record in PENDING status, then simulates generation.
*/
async generateReport(dto: GenerateReportDto, generatedBy: string): Promise<Report> {
async generateReport(
dto: { type: ReportType; period?: string },
generatedBy: string,
): Promise<Report> {
const period = dto.period || this.getDefaultPeriod(dto.type);
const title = this.buildTitle(dto.type, period);
@ -54,7 +49,9 @@ export class AdminReportsService {
// Simulate async report generation
// In production, this would dispatch to a job queue (Bull/BullMQ)
this.generateReportAsync(saved.id).catch((err) => {
this.logger.error(`Report generation failed for ${saved.id}: ${err.message}`);
this.logger.error(
`Report generation failed for ${saved.id}: ${err.message}`,
);
});
return saved;
@ -64,7 +61,7 @@ export class AdminReportsService {
* Get report by ID for download.
*/
async getReportForDownload(id: string): Promise<Report> {
const report = await this.reportRepo.findOne({ where: { id } });
const report = await this.reportRepo.findById(id);
if (!report) {
throw new NotFoundException(`Report ${id} not found`);
}
@ -79,7 +76,7 @@ export class AdminReportsService {
// Simulate processing time
await new Promise((resolve) => setTimeout(resolve, 2000));
const report = await this.reportRepo.findOne({ where: { id: reportId } });
const report = await this.reportRepo.findById(reportId);
if (!report) return;
try {

View File

@ -1,28 +1,62 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common';
import { BreakageRecord } from '../../domain/entities/breakage-record.entity';
import {
BREAKAGE_REPOSITORY,
IBreakageRepository,
} from '../../domain/repositories/breakage.repository.interface';
import { BreakageRate } from '../../domain/value-objects/breakage-rate.vo';
import { SettlementAmount } from '../../domain/value-objects/settlement-amount.vo';
@Injectable()
export class BreakageService {
constructor(@InjectRepository(BreakageRecord) private readonly repo: Repository<BreakageRecord>) {}
constructor(
@Inject(BREAKAGE_REPOSITORY)
private readonly breakageRepo: IBreakageRepository,
) {}
calculateBreakage(totalIssued: number, totalRedeemed: number, faceValue: number): { breakageAmount: number; breakageRate: number } {
/**
* Pure domain calculation: compute breakage amount and rate.
* This logic belongs in the domain layer and uses Value Objects for validation.
*/
calculateBreakage(
totalIssued: number,
totalRedeemed: number,
faceValue: number,
): { breakageAmount: SettlementAmount; breakageRate: BreakageRate } {
const totalExpired = totalIssued - totalRedeemed;
const breakageRate = totalIssued > 0 ? totalExpired / totalIssued : 0;
const breakageAmount = totalExpired * faceValue;
const breakageRate = BreakageRate.calculate(totalIssued, totalRedeemed);
const breakageAmount = SettlementAmount.create(String(totalExpired * faceValue));
return { breakageAmount, breakageRate };
}
async recordBreakage(data: { couponId: string; issuerId: string; totalIssued: number; totalRedeemed: number; totalExpired: number; faceValue: number }) {
const { breakageAmount, breakageRate } = this.calculateBreakage(data.totalIssued, data.totalRedeemed, data.faceValue);
const record = this.repo.create({
...data, breakageAmount: String(breakageAmount), breakageRate: String(breakageRate),
async recordBreakage(data: {
couponId: string;
issuerId: string;
totalIssued: number;
totalRedeemed: number;
totalExpired: number;
faceValue: number;
}): Promise<BreakageRecord> {
const { breakageAmount, breakageRate } = this.calculateBreakage(
data.totalIssued,
data.totalRedeemed,
data.faceValue,
);
const record = this.breakageRepo.create({
couponId: data.couponId,
issuerId: data.issuerId,
totalIssued: data.totalIssued,
totalRedeemed: data.totalRedeemed,
totalExpired: data.totalExpired,
breakageAmount: breakageAmount.value,
breakageRate: breakageRate.value,
});
return this.repo.save(record);
return this.breakageRepo.save(record);
}
async getByIssuerId(issuerId: string) {
return this.repo.find({ where: { issuerId }, order: { calculatedAt: 'DESC' } });
async getByIssuerId(issuerId: string): Promise<BreakageRecord[]> {
return this.breakageRepo.findByIssuerId(issuerId);
}
}

View File

@ -1,32 +1,66 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { Refund, RefundStatus } from '../../domain/entities/refund.entity';
import {
REFUND_REPOSITORY,
IRefundRepository,
} from '../../domain/repositories/refund.repository.interface';
import { SettlementAmount } from '../../domain/value-objects/settlement-amount.vo';
@Injectable()
export class RefundService {
constructor(@InjectRepository(Refund) private readonly repo: Repository<Refund>) {}
constructor(
@Inject(REFUND_REPOSITORY)
private readonly refundRepo: IRefundRepository,
) {}
async createRefund(data: { orderId: string; userId: string; amount: string; reason: string }) {
const refund = this.repo.create({ ...data, status: RefundStatus.PENDING });
return this.repo.save(refund);
async createRefund(data: {
orderId: string;
userId: string;
amount: string;
reason: string;
}): Promise<Refund> {
// Validate amount using VO
SettlementAmount.create(data.amount);
return this.refundRepo.create({
orderId: data.orderId,
userId: data.userId,
amount: data.amount,
reason: data.reason,
status: RefundStatus.PENDING,
});
}
async approveRefund(id: string, processedBy: string) {
await this.repo.update(id, { status: RefundStatus.APPROVED, processedBy, processedAt: new Date() });
async approveRefund(id: string, processedBy: string): Promise<void> {
await this.refundRepo.update(id, {
status: RefundStatus.APPROVED,
processedBy,
processedAt: new Date(),
});
}
async completeRefund(id: string) {
await this.repo.update(id, { status: RefundStatus.COMPLETED });
async completeRefund(id: string): Promise<void> {
await this.refundRepo.update(id, { status: RefundStatus.COMPLETED });
}
async rejectRefund(id: string, processedBy: string) {
await this.repo.update(id, { status: RefundStatus.REJECTED, processedBy, processedAt: new Date() });
async rejectRefund(id: string, processedBy: string): Promise<void> {
await this.refundRepo.update(id, {
status: RefundStatus.REJECTED,
processedBy,
processedAt: new Date(),
});
}
async listRefunds(page: number, limit: number, status?: string) {
const where = status ? { status: status as any } : {};
const [items, total] = await this.repo.findAndCount({ where, skip: (page - 1) * limit, take: limit, order: { createdAt: 'DESC' } });
async listRefunds(
page: number,
limit: number,
status?: string,
): Promise<{ items: Refund[]; total: number; page: number; limit: number }> {
const [items, total] = await this.refundRepo.findAndCount({
status: status as RefundStatus | undefined,
page,
limit,
});
return { items, total, page, limit };
}
}

View File

@ -1,51 +1,114 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
import { Settlement, SettlementStatus } from '../../domain/entities/settlement.entity';
import { JournalEntry, JournalType } from '../../domain/entities/journal-entry.entity';
import { JournalType } from '../../domain/entities/journal-entry.entity';
import {
SETTLEMENT_REPOSITORY,
ISettlementRepository,
} from '../../domain/repositories/settlement.repository.interface';
import { SettlementAmount } from '../../domain/value-objects/settlement-amount.vo';
@Injectable()
export class SettlementService {
private readonly logger = new Logger('SettlementService');
constructor(
@InjectRepository(Settlement) private readonly settlementRepo: Repository<Settlement>,
@InjectRepository(JournalEntry) private readonly journalRepo: Repository<JournalEntry>,
private readonly dataSource: DataSource,
@Inject(SETTLEMENT_REPOSITORY)
private readonly settlementRepo: ISettlementRepository,
) {}
async createSettlement(data: { tradeId: string; buyerId: string; sellerId: string; amount: string; buyerFee: string; sellerFee: string }) {
return this.dataSource.transaction(async (manager) => {
const settlement = manager.create(Settlement, { ...data, status: SettlementStatus.PENDING });
const saved = await manager.save(settlement);
async createSettlement(data: {
tradeId: string;
buyerId: string;
sellerId: string;
amount: string;
buyerFee: string;
sellerFee: string;
}): Promise<Settlement> {
// Validate amounts using VO
const amount = SettlementAmount.create(data.amount);
const buyerFee = SettlementAmount.create(data.buyerFee);
const sellerFee = SettlementAmount.create(data.sellerFee);
const sellerPayout = amount.subtract(sellerFee);
// Create journal entries (double-entry bookkeeping)
const entries = [
manager.create(JournalEntry, { entryType: JournalType.SETTLEMENT, referenceId: saved.id, referenceType: 'settlement', debitAccount: 'buyer_wallet', creditAccount: 'escrow', amount: data.amount, description: `Trade settlement ${data.tradeId}` }),
manager.create(JournalEntry, { entryType: JournalType.TRADE_FEE, referenceId: saved.id, referenceType: 'settlement', debitAccount: 'escrow', creditAccount: 'platform_revenue', amount: data.buyerFee, description: `Buyer fee for trade ${data.tradeId}` }),
manager.create(JournalEntry, { entryType: JournalType.TRADE_FEE, referenceId: saved.id, referenceType: 'settlement', debitAccount: 'escrow', creditAccount: 'platform_revenue', amount: data.sellerFee, description: `Seller fee for trade ${data.tradeId}` }),
manager.create(JournalEntry, { entryType: JournalType.SETTLEMENT, referenceId: saved.id, referenceType: 'settlement', debitAccount: 'escrow', creditAccount: 'seller_wallet', amount: String(parseFloat(data.amount) - parseFloat(data.sellerFee)), description: `Seller payout for trade ${data.tradeId}` }),
];
await manager.save(entries);
const settlementData: Partial<Settlement> = {
tradeId: data.tradeId,
buyerId: data.buyerId,
sellerId: data.sellerId,
amount: amount.value,
buyerFee: buyerFee.value,
sellerFee: sellerFee.value,
status: SettlementStatus.PENDING,
};
return saved;
});
// Double-entry bookkeeping journal entries
const journalEntries = [
{
entryType: JournalType.SETTLEMENT,
referenceType: 'settlement',
debitAccount: 'buyer_wallet',
creditAccount: 'escrow',
amount: amount.value,
description: `Trade settlement ${data.tradeId}`,
},
{
entryType: JournalType.TRADE_FEE,
referenceType: 'settlement',
debitAccount: 'escrow',
creditAccount: 'platform_revenue',
amount: buyerFee.value,
description: `Buyer fee for trade ${data.tradeId}`,
},
{
entryType: JournalType.TRADE_FEE,
referenceType: 'settlement',
debitAccount: 'escrow',
creditAccount: 'platform_revenue',
amount: sellerFee.value,
description: `Seller fee for trade ${data.tradeId}`,
},
{
entryType: JournalType.SETTLEMENT,
referenceType: 'settlement',
debitAccount: 'escrow',
creditAccount: 'seller_wallet',
amount: sellerPayout.value,
description: `Seller payout for trade ${data.tradeId}`,
},
];
const saved = await this.settlementRepo.createSettlementWithJournalEntries(
settlementData,
journalEntries,
);
this.logger.log(`Settlement created for trade ${data.tradeId}: ${saved.id}`);
return saved;
}
async completeSettlement(id: string) {
const settlement = await this.settlementRepo.findOne({ where: { id } });
if (!settlement) throw new NotFoundException('Settlement not found');
async completeSettlement(id: string): Promise<Settlement> {
const settlement = await this.settlementRepo.findById(id);
if (!settlement) {
throw new NotFoundException('Settlement not found');
}
settlement.status = SettlementStatus.COMPLETED;
settlement.settledAt = new Date();
return this.settlementRepo.save(settlement);
}
async getByTradeId(tradeId: string) {
return this.settlementRepo.findOne({ where: { tradeId } });
async getByTradeId(tradeId: string): Promise<Settlement | null> {
return this.settlementRepo.findByTradeId(tradeId);
}
async list(page: number, limit: number, status?: string) {
const where = status ? { status: status as any } : {};
const [items, total] = await this.settlementRepo.findAndCount({ where, skip: (page - 1) * limit, take: limit, order: { createdAt: 'DESC' } });
async list(
page: number,
limit: number,
status?: string,
): Promise<{ items: Settlement[]; total: number; page: number; limit: number }> {
const [items, total] = await this.settlementRepo.findAndCount({
status: status as SettlementStatus | undefined,
page,
limit,
});
return { items, total, page, limit };
}
}

View File

@ -2,16 +2,36 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
// Domain entities
import { Settlement } from './domain/entities/settlement.entity';
import { Refund } from './domain/entities/refund.entity';
import { BreakageRecord } from './domain/entities/breakage-record.entity';
import { JournalEntry } from './domain/entities/journal-entry.entity';
import { Report } from './domain/entities/report.entity';
// Domain repository interfaces (symbols)
import { SETTLEMENT_REPOSITORY } from './domain/repositories/settlement.repository.interface';
import { REFUND_REPOSITORY } from './domain/repositories/refund.repository.interface';
import { BREAKAGE_REPOSITORY } from './domain/repositories/breakage.repository.interface';
import { JOURNAL_ENTRY_REPOSITORY } from './domain/repositories/journal-entry.repository.interface';
import { REPORT_REPOSITORY } from './domain/repositories/report.repository.interface';
// Infrastructure persistence implementations
import { SettlementRepository } from './infrastructure/persistence/settlement.repository';
import { RefundRepository } from './infrastructure/persistence/refund.repository';
import { BreakageRepository } from './infrastructure/persistence/breakage.repository';
import { JournalEntryRepository } from './infrastructure/persistence/journal-entry.repository';
import { ReportRepository } from './infrastructure/persistence/report.repository';
// Application services
import { SettlementService } from './application/services/settlement.service';
import { RefundService } from './application/services/refund.service';
import { BreakageService } from './application/services/breakage.service';
import { AdminFinanceService } from './application/services/admin-finance.service';
import { AdminReportsService } from './application/services/admin-reports.service';
// Interface controllers
import { ClearingController } from './interface/http/controllers/clearing.controller';
import { AdminFinanceController } from './interface/http/controllers/admin-finance.controller';
import { AdminReportsController } from './interface/http/controllers/admin-reports.controller';
@ -23,7 +43,21 @@ import { AdminReportsController } from './interface/http/controllers/admin-repor
JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret' }),
],
controllers: [ClearingController, AdminFinanceController, AdminReportsController],
providers: [SettlementService, RefundService, BreakageService, AdminFinanceService, AdminReportsService],
providers: [
// Repository DI bindings (interface -> implementation)
{ provide: SETTLEMENT_REPOSITORY, useClass: SettlementRepository },
{ provide: REFUND_REPOSITORY, useClass: RefundRepository },
{ provide: BREAKAGE_REPOSITORY, useClass: BreakageRepository },
{ provide: JOURNAL_ENTRY_REPOSITORY, useClass: JournalEntryRepository },
{ provide: REPORT_REPOSITORY, useClass: ReportRepository },
// Application services
SettlementService,
RefundService,
BreakageService,
AdminFinanceService,
AdminReportsService,
],
exports: [SettlementService, RefundService, BreakageService],
})
export class ClearingModule {}

View File

@ -0,0 +1,36 @@
export interface SettlementCreatedEvent {
settlementId: string;
tradeId: string;
buyerId: string;
sellerId: string;
amount: string;
buyerFee: string;
sellerFee: string;
timestamp: string;
}
export interface SettlementCompletedEvent {
settlementId: string;
tradeId: string;
settledAt: string;
timestamp: string;
}
export interface RefundRequestedEvent {
refundId: string;
orderId: string;
userId: string;
amount: string;
reason: string;
timestamp: string;
}
export interface RefundCompletedEvent {
refundId: string;
orderId: string;
userId: string;
amount: string;
status: string;
processedBy: string | null;
timestamp: string;
}

View File

@ -0,0 +1,9 @@
import { BreakageRecord } from '../entities/breakage-record.entity';
export const BREAKAGE_REPOSITORY = Symbol('IBreakageRepository');
export interface IBreakageRepository {
save(record: BreakageRecord): Promise<BreakageRecord>;
create(data: Partial<BreakageRecord>): BreakageRecord;
findByIssuerId(issuerId: string): Promise<BreakageRecord[]>;
}

View File

@ -0,0 +1,12 @@
import { JournalEntry, JournalType } from '../entities/journal-entry.entity';
export const JOURNAL_ENTRY_REPOSITORY = Symbol('IJournalEntryRepository');
export interface IJournalEntryRepository {
save(entry: JournalEntry): Promise<JournalEntry>;
create(data: Partial<JournalEntry>): JournalEntry;
/** Aggregate: sum of amounts by entry type */
getSumByType(type: JournalType): Promise<string>;
/** QueryBuilder-based: monthly revenue by type (last 12 months) */
getMonthlyRevenueByType(type: JournalType): Promise<Array<{ month: string; revenue: string }>>;
}

View File

@ -0,0 +1,20 @@
import { Refund, RefundStatus } from '../entities/refund.entity';
export const REFUND_REPOSITORY = Symbol('IRefundRepository');
export interface IRefundRepository {
findById(id: string): Promise<Refund | null>;
findAndCount(options: {
status?: RefundStatus;
page: number;
limit: number;
}): Promise<[Refund[], number]>;
create(data: Partial<Refund>): Promise<Refund>;
update(id: string, data: Partial<Refund>): Promise<void>;
/** Aggregate: total refund count and amount */
getRefundStats(): Promise<{ count: number; total: string }>;
/** Aggregate: completed refund total */
getCompletedRefundTotal(): Promise<string>;
/** QueryBuilder-based: monthly refund counts (last 12 months) */
getMonthlyRefundCounts(): Promise<Array<{ month: string; refunds: number }>>;
}

View File

@ -0,0 +1,14 @@
import { Report, ReportType } from '../entities/report.entity';
export const REPORT_REPOSITORY = Symbol('IReportRepository');
export interface IReportRepository {
findById(id: string): Promise<Report | null>;
findAndCount(options: {
type?: ReportType;
page: number;
limit: number;
}): Promise<[Report[], number]>;
create(data: Partial<Report>): Report;
save(report: Report): Promise<Report>;
}

View File

@ -0,0 +1,23 @@
import { Settlement, SettlementStatus } from '../entities/settlement.entity';
import { JournalEntry } from '../entities/journal-entry.entity';
export const SETTLEMENT_REPOSITORY = Symbol('ISettlementRepository');
export interface ISettlementRepository {
findById(id: string): Promise<Settlement | null>;
findByTradeId(tradeId: string): Promise<Settlement | null>;
findAndCount(options: {
status?: SettlementStatus;
page: number;
limit: number;
}): Promise<[Settlement[], number]>;
save(settlement: Settlement): Promise<Settlement>;
createSettlementWithJournalEntries(
settlement: Partial<Settlement>,
journalEntries: Partial<JournalEntry>[],
): Promise<Settlement>;
/** QueryBuilder-based: stats by status */
getStatsByStatus(status: SettlementStatus): Promise<{ count: number; total: string }>;
/** QueryBuilder-based: monthly settlement counts (last 12 months) */
getMonthlySettlementCounts(): Promise<Array<{ month: string; settlements: number }>>;
}

View File

@ -0,0 +1,72 @@
/**
* Value Object: BreakageRate
* Encapsulates a breakage rate as a decimal between 0 and 1 (0% - 100%).
* Stored as a string to match DB numeric(5,4) precision.
*/
export class BreakageRate {
private constructor(private readonly _value: string) {}
/**
* Create a BreakageRate from a numeric value (0..1 range, representing 0%-100%).
*/
static create(value: number): BreakageRate {
if (isNaN(value)) {
throw new Error('Breakage rate must be a valid number');
}
if (value < 0) {
throw new Error(`Breakage rate cannot be negative, got: ${value}`);
}
if (value > 1) {
throw new Error(
`Breakage rate cannot exceed 1.0 (100%), got: ${value}`,
);
}
return new BreakageRate(String(value));
}
/**
* Reconstruct from a persisted string value (no validation, trusted source).
*/
static fromPersisted(value: string): BreakageRate {
return new BreakageRate(value);
}
/**
* Calculate breakage rate from issued and redeemed counts.
* breakageRate = (totalIssued - totalRedeemed) / totalIssued
*/
static calculate(totalIssued: number, totalRedeemed: number): BreakageRate {
if (totalIssued < 0 || totalRedeemed < 0) {
throw new Error('Counts cannot be negative');
}
if (totalRedeemed > totalIssued) {
throw new Error('Redeemed count cannot exceed issued count');
}
if (totalIssued === 0) {
return new BreakageRate('0');
}
const rate = (totalIssued - totalRedeemed) / totalIssued;
return new BreakageRate(String(rate));
}
get value(): string {
return this._value;
}
toNumber(): number {
return parseFloat(this._value);
}
/** Return the rate as a percentage string, e.g. "25.00%" */
toPercentageString(): string {
return `${(this.toNumber() * 100).toFixed(2)}%`;
}
equals(other: BreakageRate): boolean {
return this._value === other._value;
}
toString(): string {
return this._value;
}
}

View File

@ -0,0 +1,86 @@
/**
* Value Object: SettlementAmount
* Encapsulates a financial amount with validation.
* Amounts are stored as strings to preserve precision (matches DB numeric type).
*/
export class SettlementAmount {
private constructor(private readonly _value: string) {}
/**
* Create a SettlementAmount from a string value.
* Validates that the value is a positive numeric string with up to 20 digits
* and 8 decimal places.
*/
static create(value: string): SettlementAmount {
if (!value || value.trim() === '') {
throw new Error('Settlement amount cannot be empty');
}
const numeric = parseFloat(value);
if (isNaN(numeric)) {
throw new Error(`Invalid settlement amount: "${value}" is not a valid number`);
}
if (numeric < 0) {
throw new Error(`Settlement amount must be non-negative, got: ${value}`);
}
// Validate precision: up to 20 digits total, 8 decimal places
const parts = value.split('.');
const integerPart = parts[0].replace(/^-/, '');
const decimalPart = parts[1] || '';
if (integerPart.length > 12) {
throw new Error(
`Settlement amount integer part exceeds maximum 12 digits: ${integerPart.length}`,
);
}
if (decimalPart.length > 8) {
throw new Error(
`Settlement amount decimal part exceeds maximum 8 digits: ${decimalPart.length}`,
);
}
return new SettlementAmount(value);
}
/**
* Reconstruct from a persisted string value (no validation, trusted source).
*/
static fromPersisted(value: string): SettlementAmount {
return new SettlementAmount(value);
}
get value(): string {
return this._value;
}
toNumber(): number {
return parseFloat(this._value);
}
/**
* Subtract another amount and return the result.
*/
subtract(other: SettlementAmount): SettlementAmount {
const result = this.toNumber() - other.toNumber();
return SettlementAmount.create(String(result));
}
/**
* Add another amount and return the result.
*/
add(other: SettlementAmount): SettlementAmount {
const result = this.toNumber() + other.toNumber();
return SettlementAmount.create(String(result));
}
equals(other: SettlementAmount): boolean {
return this._value === other._value;
}
toString(): string {
return this._value;
}
}

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BreakageRecord } from '../../domain/entities/breakage-record.entity';
import { IBreakageRepository } from '../../domain/repositories/breakage.repository.interface';
@Injectable()
export class BreakageRepository implements IBreakageRepository {
constructor(
@InjectRepository(BreakageRecord)
private readonly repo: Repository<BreakageRecord>,
) {}
async save(record: BreakageRecord): Promise<BreakageRecord> {
return this.repo.save(record);
}
create(data: Partial<BreakageRecord>): BreakageRecord {
return this.repo.create(data);
}
async findByIssuerId(issuerId: string): Promise<BreakageRecord[]> {
return this.repo.find({
where: { issuerId },
order: { calculatedAt: 'DESC' },
});
}
}

View File

@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JournalEntry, JournalType } from '../../domain/entities/journal-entry.entity';
import { IJournalEntryRepository } from '../../domain/repositories/journal-entry.repository.interface';
@Injectable()
export class JournalEntryRepository implements IJournalEntryRepository {
constructor(
@InjectRepository(JournalEntry)
private readonly repo: Repository<JournalEntry>,
) {}
async save(entry: JournalEntry): Promise<JournalEntry> {
return this.repo.save(entry);
}
create(data: Partial<JournalEntry>): JournalEntry {
return this.repo.create(data);
}
async getSumByType(type: JournalType): Promise<string> {
const result = await this.repo
.createQueryBuilder('j')
.select('COALESCE(SUM(j.amount::numeric), 0)', 'total')
.where('j.entry_type = :type', { type })
.getRawOne();
return result?.total || '0';
}
async getMonthlyRevenueByType(
type: JournalType,
): Promise<Array<{ month: string; revenue: string }>> {
const results = await this.repo
.createQueryBuilder('j')
.select("TO_CHAR(j.created_at, 'YYYY-MM')", 'month')
.addSelect('COALESCE(SUM(j.amount::numeric), 0)', 'revenue')
.where('j.entry_type = :type', { type })
.andWhere("j.created_at >= NOW() - INTERVAL '12 months'")
.groupBy("TO_CHAR(j.created_at, 'YYYY-MM')")
.orderBy('month', 'ASC')
.getRawMany();
return results.map((row) => ({
month: row.month,
revenue: row.revenue,
}));
}
}

View File

@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Refund, RefundStatus } from '../../domain/entities/refund.entity';
import { IRefundRepository } from '../../domain/repositories/refund.repository.interface';
@Injectable()
export class RefundRepository implements IRefundRepository {
constructor(
@InjectRepository(Refund)
private readonly repo: Repository<Refund>,
) {}
async findById(id: string): Promise<Refund | null> {
return this.repo.findOne({ where: { id } });
}
async findAndCount(options: {
status?: RefundStatus;
page: number;
limit: number;
}): Promise<[Refund[], number]> {
const qb = this.repo.createQueryBuilder('r');
if (options.status) {
qb.where('r.status = :status', { status: options.status });
}
qb.orderBy('r.created_at', 'DESC')
.skip((options.page - 1) * options.limit)
.take(options.limit);
return qb.getManyAndCount();
}
async create(data: Partial<Refund>): Promise<Refund> {
const refund = this.repo.create(data);
return this.repo.save(refund);
}
async update(id: string, data: Partial<Refund>): Promise<void> {
await this.repo.update(id, data);
}
async getRefundStats(): Promise<{ count: number; total: string }> {
const result = await this.repo
.createQueryBuilder('r')
.select('COUNT(r.id)', 'count')
.addSelect('COALESCE(SUM(r.amount::numeric), 0)', 'total')
.getRawOne();
return {
count: parseInt(result?.count || '0', 10),
total: result?.total || '0',
};
}
async getCompletedRefundTotal(): Promise<string> {
const result = await this.repo
.createQueryBuilder('r')
.select('COALESCE(SUM(r.amount::numeric), 0)', 'total')
.where('r.status = :status', { status: RefundStatus.COMPLETED })
.getRawOne();
return result?.total || '0';
}
async getMonthlyRefundCounts(): Promise<
Array<{ month: string; refunds: number }>
> {
const results = await this.repo
.createQueryBuilder('r')
.select("TO_CHAR(r.created_at, 'YYYY-MM')", 'month')
.addSelect('COUNT(r.id)', 'refunds')
.where("r.created_at >= NOW() - INTERVAL '12 months'")
.groupBy("TO_CHAR(r.created_at, 'YYYY-MM')")
.getRawMany();
return results.map((row) => ({
month: row.month,
refunds: parseInt(row.refunds, 10),
}));
}
}

View File

@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Report, ReportType } from '../../domain/entities/report.entity';
import { IReportRepository } from '../../domain/repositories/report.repository.interface';
@Injectable()
export class ReportRepository implements IReportRepository {
constructor(
@InjectRepository(Report)
private readonly repo: Repository<Report>,
) {}
async findById(id: string): Promise<Report | null> {
return this.repo.findOne({ where: { id } });
}
async findAndCount(options: {
type?: ReportType;
page: number;
limit: number;
}): Promise<[Report[], number]> {
const qb = this.repo.createQueryBuilder('r');
if (options.type) {
qb.where('r.type = :type', { type: options.type });
}
qb.orderBy('r.created_at', 'DESC')
.skip((options.page - 1) * options.limit)
.take(options.limit);
return qb.getManyAndCount();
}
create(data: Partial<Report>): Report {
return this.repo.create(data);
}
async save(report: Report): Promise<Report> {
return this.repo.save(report);
}
}

View File

@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Settlement, SettlementStatus } from '../../domain/entities/settlement.entity';
import { JournalEntry } from '../../domain/entities/journal-entry.entity';
import { ISettlementRepository } from '../../domain/repositories/settlement.repository.interface';
@Injectable()
export class SettlementRepository implements ISettlementRepository {
constructor(
@InjectRepository(Settlement)
private readonly repo: Repository<Settlement>,
private readonly dataSource: DataSource,
) {}
async findById(id: string): Promise<Settlement | null> {
return this.repo.findOne({ where: { id } });
}
async findByTradeId(tradeId: string): Promise<Settlement | null> {
return this.repo.findOne({ where: { tradeId } });
}
async findAndCount(options: {
status?: SettlementStatus;
page: number;
limit: number;
}): Promise<[Settlement[], number]> {
const qb = this.repo.createQueryBuilder('s');
if (options.status) {
qb.where('s.status = :status', { status: options.status });
}
qb.orderBy('s.created_at', 'DESC')
.skip((options.page - 1) * options.limit)
.take(options.limit);
return qb.getManyAndCount();
}
async save(settlement: Settlement): Promise<Settlement> {
return this.repo.save(settlement);
}
async createSettlementWithJournalEntries(
settlementData: Partial<Settlement>,
journalEntriesData: Partial<JournalEntry>[],
): Promise<Settlement> {
return this.dataSource.transaction(async (manager) => {
const settlement = manager.create(Settlement, settlementData);
const saved = await manager.save(settlement);
const entries = journalEntriesData.map((entry) =>
manager.create(JournalEntry, entry),
);
// Set referenceId from the saved settlement
for (const entry of entries) {
if (!entry.referenceId) {
entry.referenceId = saved.id;
}
}
await manager.save(entries);
return saved;
});
}
async getStatsByStatus(
status: SettlementStatus,
): Promise<{ count: number; total: string }> {
const result = await this.repo
.createQueryBuilder('s')
.select('COUNT(s.id)', 'count')
.addSelect('COALESCE(SUM(s.amount::numeric), 0)', 'total')
.where('s.status = :status', { status })
.getRawOne();
return {
count: parseInt(result?.count || '0', 10),
total: result?.total || '0',
};
}
async getMonthlySettlementCounts(): Promise<
Array<{ month: string; settlements: number }>
> {
const results = await this.repo
.createQueryBuilder('s')
.select("TO_CHAR(s.created_at, 'YYYY-MM')", 'month')
.addSelect('COUNT(s.id)', 'settlements')
.where("s.created_at >= NOW() - INTERVAL '12 months'")
.groupBy("TO_CHAR(s.created_at, 'YYYY-MM')")
.getRawMany();
return results.map((row) => ({
month: row.month,
settlements: parseInt(row.settlements, 10),
}));
}
}

View File

@ -1,7 +1,9 @@
import { Controller, Get, Post, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, RolesGuard, Roles, UserRole } from '@genex/common';
import { AdminFinanceService } from '../../../application/services/admin-finance.service';
import { ListSettlementsQueryDto } from '../dto/settlement.dto';
import { ListRefundsQueryDto } from '../dto/refund.dto';
@ApiTags('Admin - Finance')
@Controller('admin/finance')
@ -19,15 +21,15 @@ export class AdminFinanceController {
@Get('settlements')
@ApiOperation({ summary: 'Settlement queue (paginated, filter by status)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'status', required: false, enum: ['pending', 'processing', 'completed', 'failed'] })
async getSettlements(
@Query('page') page = '1',
@Query('limit') limit = '20',
@Query('status') status?: string,
) {
return { code: 0, data: await this.adminFinanceService.getSettlements(+page, +limit, status) };
async getSettlements(@Query() query: ListSettlementsQueryDto) {
return {
code: 0,
data: await this.adminFinanceService.getSettlements(
+(query.page || '1'),
+(query.limit || '20'),
query.status,
),
};
}
@Get('revenue-trend')
@ -50,14 +52,14 @@ export class AdminFinanceController {
@Get('consumer-refunds')
@ApiOperation({ summary: 'Consumer refund tracking (paginated)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'status', required: false, enum: ['pending', 'approved', 'completed', 'rejected'] })
async getConsumerRefunds(
@Query('page') page = '1',
@Query('limit') limit = '20',
@Query('status') status?: string,
) {
return { code: 0, data: await this.adminFinanceService.getConsumerRefunds(+page, +limit, status) };
async getConsumerRefunds(@Query() query: ListRefundsQueryDto) {
return {
code: 0,
data: await this.adminFinanceService.getConsumerRefunds(
+(query.page || '1'),
+(query.limit || '20'),
query.status,
),
};
}
}

View File

@ -1,8 +1,9 @@
import { Controller, Get, Post, Param, Query, Body, UseGuards, Req, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, RolesGuard, Roles, UserRole } from '@genex/common';
import { AdminReportsService, GenerateReportDto } from '../../../application/services/admin-reports.service';
import { AdminReportsService } from '../../../application/services/admin-reports.service';
import { ReportStatus } from '../../../domain/entities/report.entity';
import { GenerateReportDto, ListReportsQueryDto } from '../dto/report.dto';
@ApiTags('Admin - Reports')
@Controller('admin/reports')
@ -14,15 +15,15 @@ export class AdminReportsController {
@Get()
@ApiOperation({ summary: 'List all generated reports' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'type', required: false, enum: ['daily', 'monthly', 'quarterly', 'annual'] })
async listReports(
@Query('page') page = '1',
@Query('limit') limit = '20',
@Query('type') type?: string,
) {
return { code: 0, data: await this.adminReportsService.listReports(+page, +limit, type) };
async listReports(@Query() query: ListReportsQueryDto) {
return {
code: 0,
data: await this.adminReportsService.listReports(
+(query.page || '1'),
+(query.limit || '20'),
query.type,
),
};
}
@Post('generate')

View File

@ -4,6 +4,8 @@ import { AuthGuard } from '@nestjs/passport';
import { SettlementService } from '../../../application/services/settlement.service';
import { RefundService } from '../../../application/services/refund.service';
import { BreakageService } from '../../../application/services/breakage.service';
import { ListSettlementsQueryDto } from '../dto/settlement.dto';
import { CreateRefundDto, ApproveRefundDto, ListRefundsQueryDto } from '../dto/refund.dto';
@ApiTags('Clearing')
@Controller('payments')
@ -18,30 +20,46 @@ export class ClearingController {
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: 'List settlements' })
async listSettlements(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status?: string) {
return { code: 0, data: await this.settlementService.list(+page, +limit, status) };
async listSettlements(@Query() query: ListSettlementsQueryDto) {
return {
code: 0,
data: await this.settlementService.list(
+(query.page || '1'),
+(query.limit || '20'),
query.status,
),
};
}
@Post('refunds')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: 'Request a refund' })
async createRefund(@Body() body: { orderId: string; userId: string; amount: string; reason: string }) {
async createRefund(@Body() body: CreateRefundDto) {
return { code: 0, data: await this.refundService.createRefund(body) };
}
@Put('refunds/:id/approve')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async approveRefund(@Param('id') id: string, @Body('processedBy') processedBy: string) {
await this.refundService.approveRefund(id, processedBy);
@ApiOperation({ summary: 'Approve a refund' })
async approveRefund(@Param('id') id: string, @Body() body: ApproveRefundDto) {
await this.refundService.approveRefund(id, body.processedBy);
return { code: 0, data: null };
}
@Get('refunds')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async listRefunds(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status?: string) {
return { code: 0, data: await this.refundService.listRefunds(+page, +limit, status) };
@ApiOperation({ summary: 'List refunds' })
async listRefunds(@Query() query: ListRefundsQueryDto) {
return {
code: 0,
data: await this.refundService.listRefunds(
+(query.page || '1'),
+(query.limit || '20'),
query.status,
),
};
}
}

View File

@ -0,0 +1,4 @@
export * from './pagination.dto';
export * from './settlement.dto';
export * from './refund.dto';
export * from './report.dto';

View File

@ -0,0 +1,14 @@
import { IsOptional, IsNumberString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class PaginationQueryDto {
@ApiPropertyOptional({ example: '1', description: 'Page number (1-based)' })
@IsOptional()
@IsNumberString()
page?: string = '1';
@ApiPropertyOptional({ example: '20', description: 'Items per page' })
@IsOptional()
@IsNumberString()
limit?: string = '20';
}

View File

@ -0,0 +1,39 @@
import { IsString, IsUUID, IsNumberString, IsOptional, IsEnum, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { RefundStatus } from '../../../domain/entities/refund.entity';
import { PaginationQueryDto } from './pagination.dto';
export class CreateRefundDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: 'Order ID' })
@IsUUID()
orderId: string;
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001', description: 'User ID' })
@IsUUID()
userId: string;
@ApiProperty({ example: '50.00', description: 'Refund amount' })
@IsNumberString()
amount: string;
@ApiProperty({ example: 'Product defective', description: 'Reason for refund' })
@IsString()
@MaxLength(200)
reason: string;
}
export class ApproveRefundDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440003', description: 'Admin user ID who approves the refund' })
@IsUUID()
processedBy: string;
}
export class ListRefundsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({
enum: RefundStatus,
description: 'Filter by refund status',
})
@IsOptional()
@IsEnum(RefundStatus)
status?: RefundStatus;
}

View File

@ -0,0 +1,33 @@
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ReportType } from '../../../domain/entities/report.entity';
import { PaginationQueryDto } from './pagination.dto';
export class GenerateReportDto {
@ApiProperty({
enum: ReportType,
example: ReportType.MONTHLY,
description: 'Report type (daily, monthly, quarterly, annual)',
})
@IsEnum(ReportType)
type: ReportType;
@ApiPropertyOptional({
example: '2025-01',
description: 'Report period (auto-generated if omitted)',
})
@IsOptional()
@IsString()
@MaxLength(50)
period?: string;
}
export class ListReportsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({
enum: ReportType,
description: 'Filter by report type',
})
@IsOptional()
@IsEnum(ReportType)
type?: ReportType;
}

View File

@ -0,0 +1,40 @@
import { IsString, IsUUID, IsNumberString, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { SettlementStatus } from '../../../domain/entities/settlement.entity';
import { PaginationQueryDto } from './pagination.dto';
export class CreateSettlementDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: 'Trade ID' })
@IsUUID()
tradeId: string;
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001', description: 'Buyer user ID' })
@IsUUID()
buyerId: string;
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440002', description: 'Seller user ID' })
@IsUUID()
sellerId: string;
@ApiProperty({ example: '100.00', description: 'Settlement amount' })
@IsNumberString()
amount: string;
@ApiProperty({ example: '1.50', description: 'Buyer fee amount' })
@IsNumberString()
buyerFee: string;
@ApiProperty({ example: '1.50', description: 'Seller fee amount' })
@IsNumberString()
sellerFee: string;
}
export class ListSettlementsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({
enum: SettlementStatus,
description: 'Filter by settlement status',
})
@IsOptional()
@IsEnum(SettlementStatus)
status?: SettlementStatus;
}

View File

@ -1,37 +1,47 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SarReport } from '../../domain/entities/sar-report.entity';
import { AuditLog } from '../../domain/entities/audit-log.entity';
import { AmlAlert } from '../../domain/entities/aml-alert.entity';
import { TravelRuleRecord } from '../../domain/entities/travel-rule-record.entity';
import { Inject, Injectable, Logger } from '@nestjs/common';
import {
SAR_REPORT_REPOSITORY,
ISarReportRepository,
} from '../../domain/repositories/sar-report.repository.interface';
import {
AUDIT_LOG_REPOSITORY,
IAuditLogRepository,
} from '../../domain/repositories/audit-log.repository.interface';
import {
AML_ALERT_REPOSITORY,
IAmlAlertRepository,
} from '../../domain/repositories/aml-alert.repository.interface';
import {
TRAVEL_RULE_REPOSITORY,
ITravelRuleRepository,
} from '../../domain/repositories/travel-rule.repository.interface';
import {
AUDIT_LOGGER_SERVICE,
IAuditLoggerService,
} from '../../domain/ports/audit-logger.interface';
@Injectable()
export class AdminComplianceService {
private readonly logger = new Logger('AdminComplianceService');
constructor(
@InjectRepository(SarReport) private readonly sarRepo: Repository<SarReport>,
@InjectRepository(AuditLog) private readonly auditRepo: Repository<AuditLog>,
@InjectRepository(AmlAlert) private readonly alertRepo: Repository<AmlAlert>,
@InjectRepository(TravelRuleRecord) private readonly travelRepo: Repository<TravelRuleRecord>,
@Inject(SAR_REPORT_REPOSITORY)
private readonly sarRepo: ISarReportRepository,
@Inject(AUDIT_LOG_REPOSITORY)
private readonly auditRepo: IAuditLogRepository,
@Inject(AML_ALERT_REPOSITORY)
private readonly alertRepo: IAmlAlertRepository,
@Inject(TRAVEL_RULE_REPOSITORY)
private readonly travelRepo: ITravelRuleRepository,
@Inject(AUDIT_LOGGER_SERVICE)
private readonly auditLogger: IAuditLoggerService,
) {}
// ───────────── SAR Management ─────────────
/** List SAR reports (paginated, with optional status filter) */
async listSarReports(page: number, limit: number, status?: string) {
const qb = this.sarRepo.createQueryBuilder('sar');
if (status) {
qb.andWhere('sar.filing_status = :status', { status });
}
qb.orderBy('sar.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await qb.getManyAndCount();
const [items, total] = await this.sarRepo.findPaginated(page, limit, { status });
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
@ -63,29 +73,13 @@ export class AdminComplianceService {
startDate?: string,
endDate?: string,
) {
const qb = this.auditRepo.createQueryBuilder('log');
if (action) {
qb.andWhere('log.action = :action', { action });
}
if (adminId) {
qb.andWhere('log.admin_id = :adminId', { adminId });
}
if (resource) {
qb.andWhere('log.resource = :resource', { resource });
}
if (startDate) {
qb.andWhere('log.created_at >= :startDate', { startDate });
}
if (endDate) {
qb.andWhere('log.created_at <= :endDate', { endDate });
}
qb.orderBy('log.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await qb.getManyAndCount();
const [items, total] = await this.auditRepo.findPaginated(page, limit, {
action,
adminId,
resource,
startDate,
endDate,
});
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
@ -140,10 +134,7 @@ export class AdminComplianceService {
case 'aml':
const [alertCount, highRisk] = await Promise.all([
this.alertRepo.count(),
this.alertRepo
.createQueryBuilder('a')
.where('a.risk_score >= :score', { score: 70 })
.getCount(),
this.alertRepo.countHighRisk(70),
]);
reportData = { alertCount, highRiskCount: highRisk, generatedAt: timestamp };
break;
@ -160,18 +151,16 @@ export class AdminComplianceService {
reportData = { type: reportType, status: 'unsupported', generatedAt: timestamp };
}
// Audit log the report generation
const log = this.auditRepo.create({
// Audit log the report generation via shared service
await this.auditLogger.log({
adminId,
adminName,
action: 'generate_report',
resource: 'compliance_report',
resourceId: null,
ipAddress: ipAddress || null,
result: 'success',
ipAddress,
details: { reportType, ...reportData },
});
await this.auditRepo.save(log);
this.logger.log(`Report generated: type=${reportType} by admin=${adminId}`);
return { reportType, ...reportData };

View File

@ -1,40 +1,34 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Dispute, DisputeStatus } from '../../domain/entities/dispute.entity';
import { AuditLog } from '../../domain/entities/audit-log.entity';
import {
DISPUTE_REPOSITORY,
IDisputeRepository,
} from '../../domain/repositories/dispute.repository.interface';
import {
AUDIT_LOGGER_SERVICE,
IAuditLoggerService,
} from '../../domain/ports/audit-logger.interface';
@Injectable()
export class AdminDisputeService {
private readonly logger = new Logger('AdminDisputeService');
constructor(
@InjectRepository(Dispute) private readonly disputeRepo: Repository<Dispute>,
@InjectRepository(AuditLog) private readonly auditRepo: Repository<AuditLog>,
@Inject(DISPUTE_REPOSITORY)
private readonly disputeRepo: IDisputeRepository,
@Inject(AUDIT_LOGGER_SERVICE)
private readonly auditLogger: IAuditLoggerService,
) {}
/** List disputes (paginated, filterable by status and type) */
async listDisputes(page: number, limit: number, status?: string, type?: string) {
const qb = this.disputeRepo.createQueryBuilder('dispute');
if (status) {
qb.andWhere('dispute.status = :status', { status });
}
if (type) {
qb.andWhere('dispute.type = :type', { type });
}
qb.orderBy('dispute.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await qb.getManyAndCount();
const [items, total] = await this.disputeRepo.findPaginated(page, limit, { status, type });
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
/** Get dispute detail by ID */
async getDisputeDetail(id: string) {
const dispute = await this.disputeRepo.findOne({ where: { id } });
const dispute = await this.disputeRepo.findById(id);
if (!dispute) throw new NotFoundException('Dispute not found');
return dispute;
}
@ -47,7 +41,7 @@ export class AdminDisputeService {
adminName: string,
ipAddress?: string,
) {
const dispute = await this.disputeRepo.findOne({ where: { id } });
const dispute = await this.disputeRepo.findById(id);
if (!dispute) throw new NotFoundException('Dispute not found');
const previousStatus = dispute.status;
@ -57,11 +51,19 @@ export class AdminDisputeService {
const saved = await this.disputeRepo.save(dispute);
// Audit log
await this.logAction(adminId, adminName, 'resolve_dispute', 'dispute', id, ipAddress, {
previousStatus,
newStatus: dispute.status,
resolution: data.resolution,
// Audit log via shared service
await this.auditLogger.log({
adminId,
adminName,
action: 'resolve_dispute',
resource: 'dispute',
resourceId: id,
ipAddress,
details: {
previousStatus,
newStatus: dispute.status,
resolution: data.resolution,
},
});
this.logger.log(`Dispute resolved: id=${id}, status=${dispute.status}, by admin=${adminId}`);
@ -76,7 +78,7 @@ export class AdminDisputeService {
adminName: string,
ipAddress?: string,
) {
const dispute = await this.disputeRepo.findOne({ where: { id } });
const dispute = await this.disputeRepo.findById(id);
if (!dispute) throw new NotFoundException('Dispute not found');
const previousStatus = dispute.status;
@ -87,37 +89,22 @@ export class AdminDisputeService {
const saved = await this.disputeRepo.save(dispute);
// Audit log
await this.logAction(adminId, adminName, 'arbitrate_dispute', 'dispute', id, ipAddress, {
previousStatus,
decision: data.decision,
notes: data.notes,
// Audit log via shared service
await this.auditLogger.log({
adminId,
adminName,
action: 'arbitrate_dispute',
resource: 'dispute',
resourceId: id,
ipAddress,
details: {
previousStatus,
decision: data.decision,
notes: data.notes,
},
});
this.logger.log(`Dispute arbitrated: id=${id}, by admin=${adminId}`);
return saved;
}
/** Write an entry to the audit log */
private async logAction(
adminId: string,
adminName: string,
action: string,
resource: string,
resourceId: string,
ipAddress?: string,
details?: any,
) {
const log = this.auditRepo.create({
adminId,
adminName,
action,
resource,
resourceId,
ipAddress: ipAddress || null,
result: 'success',
details: details || null,
});
await this.auditRepo.save(log);
}
}

View File

@ -1,16 +1,23 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InsuranceClaim, ClaimStatus } from '../../domain/entities/insurance-claim.entity';
import { AuditLog } from '../../domain/entities/audit-log.entity';
import {
INSURANCE_CLAIM_REPOSITORY,
IInsuranceClaimRepository,
} from '../../domain/repositories/insurance-claim.repository.interface';
import {
AUDIT_LOGGER_SERVICE,
IAuditLoggerService,
} from '../../domain/ports/audit-logger.interface';
@Injectable()
export class AdminInsuranceService {
private readonly logger = new Logger('AdminInsuranceService');
constructor(
@InjectRepository(InsuranceClaim) private readonly claimRepo: Repository<InsuranceClaim>,
@InjectRepository(AuditLog) private readonly auditRepo: Repository<AuditLog>,
@Inject(INSURANCE_CLAIM_REPOSITORY)
private readonly claimRepo: IInsuranceClaimRepository,
@Inject(AUDIT_LOGGER_SERVICE)
private readonly auditLogger: IAuditLoggerService,
) {}
/** Protection fund statistics */
@ -22,17 +29,10 @@ export class AdminInsuranceService {
this.claimRepo.count({ where: { status: ClaimStatus.REJECTED } }),
]);
const paidAmountResult = await this.claimRepo
.createQueryBuilder('claim')
.select('COALESCE(SUM(claim.amount), 0)', 'totalPaid')
.where('claim.status = :status', { status: ClaimStatus.PAID })
.getRawOne();
const pendingAmountResult = await this.claimRepo
.createQueryBuilder('claim')
.select('COALESCE(SUM(claim.amount), 0)', 'totalPending')
.where('claim.status = :status', { status: ClaimStatus.PENDING })
.getRawOne();
const [totalPaidAmount, totalPendingAmount] = await Promise.all([
this.claimRepo.sumAmountByStatus(ClaimStatus.PAID),
this.claimRepo.sumAmountByStatus(ClaimStatus.PENDING),
]);
return {
totalClaims,
@ -40,25 +40,15 @@ export class AdminInsuranceService {
paidClaims,
rejectedClaims,
processingClaims: totalClaims - pendingClaims - paidClaims - rejectedClaims,
totalPaidAmount: paidAmountResult?.totalPaid || '0',
totalPendingAmount: pendingAmountResult?.totalPending || '0',
totalPaidAmount,
totalPendingAmount,
fundBalance: '1000000.00', // Mock: protection fund balance
};
}
/** List insurance claims (paginated, filterable by status) */
async listClaims(page: number, limit: number, status?: string) {
const qb = this.claimRepo.createQueryBuilder('claim');
if (status) {
qb.andWhere('claim.status = :status', { status });
}
qb.orderBy('claim.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await qb.getManyAndCount();
const [items, total] = await this.claimRepo.findPaginated(page, limit, { status });
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
@ -69,7 +59,7 @@ export class AdminInsuranceService {
adminName: string,
ipAddress?: string,
) {
const claim = await this.claimRepo.findOne({ where: { id } });
const claim = await this.claimRepo.findById(id);
if (!claim) throw new NotFoundException('Insurance claim not found');
const previousStatus = claim.status;
@ -78,11 +68,19 @@ export class AdminInsuranceService {
const saved = await this.claimRepo.save(claim);
// Audit log
await this.logAction(adminId, adminName, 'approve_claim', 'insurance_claim', id, ipAddress, {
previousStatus,
amount: claim.amount,
userId: claim.userId,
// Audit log via shared service
await this.auditLogger.log({
adminId,
adminName,
action: 'approve_claim',
resource: 'insurance_claim',
resourceId: id,
ipAddress,
details: {
previousStatus,
amount: claim.amount,
userId: claim.userId,
},
});
this.logger.log(`Insurance claim approved: id=${id}, amount=${claim.amount}, by admin=${adminId}`);
@ -97,7 +95,7 @@ export class AdminInsuranceService {
adminName: string,
ipAddress?: string,
) {
const claim = await this.claimRepo.findOne({ where: { id } });
const claim = await this.claimRepo.findById(id);
if (!claim) throw new NotFoundException('Insurance claim not found');
const previousStatus = claim.status;
@ -109,38 +107,23 @@ export class AdminInsuranceService {
const saved = await this.claimRepo.save(claim);
// Audit log
await this.logAction(adminId, adminName, 'reject_claim', 'insurance_claim', id, ipAddress, {
previousStatus,
amount: claim.amount,
userId: claim.userId,
rejectionReason: data.reason,
// Audit log via shared service
await this.auditLogger.log({
adminId,
adminName,
action: 'reject_claim',
resource: 'insurance_claim',
resourceId: id,
ipAddress,
details: {
previousStatus,
amount: claim.amount,
userId: claim.userId,
rejectionReason: data.reason,
},
});
this.logger.log(`Insurance claim rejected: id=${id}, by admin=${adminId}`);
return saved;
}
/** Write an entry to the audit log */
private async logAction(
adminId: string,
adminName: string,
action: string,
resource: string,
resourceId: string,
ipAddress?: string,
details?: any,
) {
const log = this.auditRepo.create({
adminId,
adminName,
action,
resource,
resourceId,
ipAddress: ipAddress || null,
result: 'success',
details: details || null,
});
await this.auditRepo.save(log);
}
}

View File

@ -1,20 +1,35 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AmlAlert, AlertStatus } from '../../domain/entities/aml-alert.entity';
import { OfacScreening } from '../../domain/entities/ofac-screening.entity';
import { SarReport } from '../../domain/entities/sar-report.entity';
import { AuditLog } from '../../domain/entities/audit-log.entity';
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AlertStatus } from '../../domain/entities/aml-alert.entity';
import {
AML_ALERT_REPOSITORY,
IAmlAlertRepository,
} from '../../domain/repositories/aml-alert.repository.interface';
import {
OFAC_SCREENING_REPOSITORY,
IOfacScreeningRepository,
} from '../../domain/repositories/ofac-screening.repository.interface';
import {
SAR_REPORT_REPOSITORY,
ISarReportRepository,
} from '../../domain/repositories/sar-report.repository.interface';
import {
AUDIT_LOGGER_SERVICE,
IAuditLoggerService,
} from '../../domain/ports/audit-logger.interface';
@Injectable()
export class AdminRiskService {
private readonly logger = new Logger('AdminRiskService');
constructor(
@InjectRepository(AmlAlert) private readonly alertRepo: Repository<AmlAlert>,
@InjectRepository(OfacScreening) private readonly ofacRepo: Repository<OfacScreening>,
@InjectRepository(SarReport) private readonly sarRepo: Repository<SarReport>,
@InjectRepository(AuditLog) private readonly auditRepo: Repository<AuditLog>,
@Inject(AML_ALERT_REPOSITORY)
private readonly alertRepo: IAmlAlertRepository,
@Inject(OFAC_SCREENING_REPOSITORY)
private readonly ofacRepo: IOfacScreeningRepository,
@Inject(SAR_REPORT_REPOSITORY)
private readonly sarRepo: ISarReportRepository,
@Inject(AUDIT_LOGGER_SERVICE)
private readonly auditLogger: IAuditLoggerService,
) {}
/** Risk dashboard: aggregate stats across alerts, screenings, SARs */
@ -27,12 +42,7 @@ export class AdminRiskService {
{ status: AlertStatus.ESCALATED },
],
}),
this.alertRepo
.createQueryBuilder('a')
.where('a.risk_score >= :threshold', { threshold: 70 })
.andWhere('a.status != :resolved', { resolved: AlertStatus.RESOLVED })
.andWhere('a.status != :dismissed', { dismissed: AlertStatus.DISMISSED })
.getCount(),
this.alertRepo.countHighRiskActive(70),
this.ofacRepo.count({ where: { isMatch: true } }),
]);
@ -51,45 +61,19 @@ export class AdminRiskService {
/** List active risk/AML alerts (paginated) */
async listAlerts(page: number, limit: number, status?: string, pattern?: string) {
const qb = this.alertRepo.createQueryBuilder('alert');
if (status) {
qb.andWhere('alert.status = :status', { status });
}
if (pattern) {
qb.andWhere('alert.pattern = :pattern', { pattern });
}
qb.orderBy('alert.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await qb.getManyAndCount();
const [items, total] = await this.alertRepo.findPaginated(page, limit, { status, pattern });
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
/** List flagged suspicious transactions (high risk score) */
async listSuspiciousTrades(page: number, limit: number) {
const qb = this.alertRepo
.createQueryBuilder('alert')
.where('alert.risk_score >= :threshold', { threshold: 70 })
.orderBy('alert.risk_score', 'DESC')
.addOrderBy('alert.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await qb.getManyAndCount();
const [items, total] = await this.alertRepo.findSuspicious(page, limit, 70);
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
/** List blacklisted users from OFAC screening */
async listBlacklist(page: number, limit: number) {
const [items, total] = await this.ofacRepo.findAndCount({
where: { isMatch: true },
order: { screenedAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
const [items, total] = await this.ofacRepo.findMatchesPaginated(page, limit);
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
@ -100,17 +84,26 @@ export class AdminRiskService {
adminName: string,
ipAddress?: string,
) {
const alert = await this.alertRepo.findOne({ where: { id: alertId } });
const alert = await this.alertRepo.findById(alertId);
if (!alert) throw new NotFoundException('Alert not found');
const previousStatus = alert.status;
alert.status = AlertStatus.ESCALATED;
alert.resolution = `Account frozen by admin ${adminName}`;
await this.alertRepo.save(alert);
// Log the admin action
await this.logAction(adminId, adminName, 'freeze_account', 'aml_alert', alertId, ipAddress, {
userId: alert.userId,
previousStatus: alert.status,
// Log the admin action via shared service
await this.auditLogger.log({
adminId,
adminName,
action: 'freeze_account',
resource: 'aml_alert',
resourceId: alertId,
ipAddress,
details: {
userId: alert.userId,
previousStatus,
},
});
this.logger.warn(`Account frozen: alert=${alertId}, user=${alert.userId}, by admin=${adminId}`);
@ -124,7 +117,7 @@ export class AdminRiskService {
adminName: string,
ipAddress?: string,
) {
const alert = await this.alertRepo.findOne({ where: { id: alertId } });
const alert = await this.alertRepo.findById(alertId);
if (!alert) throw new NotFoundException('Alert not found');
const sar = this.sarRepo.create({
@ -140,36 +133,21 @@ export class AdminRiskService {
alert.status = AlertStatus.ESCALATED;
await this.alertRepo.save(alert);
// Log the admin action
await this.logAction(adminId, adminName, 'generate_sar', 'sar_report', saved.id, ipAddress, {
alertId,
userId: alert.userId,
// Log the admin action via shared service
await this.auditLogger.log({
adminId,
adminName,
action: 'generate_sar',
resource: 'sar_report',
resourceId: saved.id,
ipAddress,
details: {
alertId,
userId: alert.userId,
},
});
this.logger.log(`SAR generated: sar=${saved.id} from alert=${alertId}, by admin=${adminId}`);
return saved;
}
/** Write an entry to the audit log */
private async logAction(
adminId: string,
adminName: string,
action: string,
resource: string,
resourceId: string,
ipAddress?: string,
details?: any,
) {
const log = this.auditRepo.create({
adminId,
adminName,
action,
resource,
resourceId,
ipAddress: ipAddress || null,
result: 'success',
details: details || null,
});
await this.auditRepo.save(log);
}
}

View File

@ -1,12 +1,20 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { AmlAlert, AmlPattern, AlertStatus } from '../../domain/entities/aml-alert.entity';
import {
AML_ALERT_REPOSITORY,
IAmlAlertRepository,
} from '../../domain/repositories/aml-alert.repository.interface';
import { RiskScore } from '../../domain/value-objects/risk-score.vo';
import { AlertSeverity } from '../../domain/value-objects/alert-severity.vo';
@Injectable()
export class AmlService {
private readonly logger = new Logger('AMLService');
constructor(@InjectRepository(AmlAlert) private readonly repo: Repository<AmlAlert>) {}
constructor(
@Inject(AML_ALERT_REPOSITORY)
private readonly repo: IAmlAlertRepository,
) {}
/**
* Analyze a transaction for AML patterns.
@ -70,15 +78,20 @@ export class AmlService {
description: string,
evidence: any,
): Promise<AmlAlert> {
const score = RiskScore.create(riskScore);
const severity = AlertSeverity.fromRiskScore(riskScore);
const alert = this.repo.create({
userId,
pattern,
riskScore: String(riskScore),
riskScore: score.toString(),
description,
evidence,
status: AlertStatus.OPEN,
});
this.logger.warn(`AML Alert: ${pattern} for user ${userId}, risk=${riskScore}`);
this.logger.warn(
`AML Alert: ${pattern} for user ${userId}, risk=${score.value}, severity=${severity.value}`,
);
return this.repo.save(alert);
}

View File

@ -1,11 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { OfacScreening } from '../../domain/entities/ofac-screening.entity';
import {
OFAC_SCREENING_REPOSITORY,
IOfacScreeningRepository,
} from '../../domain/repositories/ofac-screening.repository.interface';
@Injectable()
export class OfacService {
constructor(@InjectRepository(OfacScreening) private readonly repo: Repository<OfacScreening>) {}
constructor(
@Inject(OFAC_SCREENING_REPOSITORY)
private readonly repo: IOfacScreeningRepository,
) {}
/**
* Screen a name against OFAC SDN list (mock implementation).
@ -25,6 +30,6 @@ export class OfacService {
}
async getScreeningsByUserId(userId: string) {
return this.repo.find({ where: { userId }, order: { screenedAt: 'DESC' } });
return this.repo.findByUserId(userId);
}
}

View File

@ -1,11 +1,15 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SarReport } from '../../domain/entities/sar-report.entity';
import { Inject, Injectable } from '@nestjs/common';
import {
SAR_REPORT_REPOSITORY,
ISarReportRepository,
} from '../../domain/repositories/sar-report.repository.interface';
@Injectable()
export class SarService {
constructor(@InjectRepository(SarReport) private readonly repo: Repository<SarReport>) {}
constructor(
@Inject(SAR_REPORT_REPOSITORY)
private readonly repo: ISarReportRepository,
) {}
async createReport(data: {
alertId: string;
@ -26,11 +30,7 @@ export class SarService {
}
async listReports(page: number, limit: number) {
const [items, total] = await this.repo.findAndCount({
skip: (page - 1) * limit,
take: limit,
order: { createdAt: 'DESC' },
});
const [items, total] = await this.repo.findPaginated(page, limit);
return { items, total, page, limit };
}
}

View File

@ -1,11 +1,16 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { TravelRuleRecord } from '../../domain/entities/travel-rule-record.entity';
import {
TRAVEL_RULE_REPOSITORY,
ITravelRuleRepository,
} from '../../domain/repositories/travel-rule.repository.interface';
@Injectable()
export class TravelRuleService {
constructor(@InjectRepository(TravelRuleRecord) private readonly repo: Repository<TravelRuleRecord>) {}
constructor(
@Inject(TRAVEL_RULE_REPOSITORY)
private readonly repo: ITravelRuleRepository,
) {}
/**
* Check if a transfer requires Travel Rule compliance (>= $3,000).
@ -44,6 +49,6 @@ export class TravelRuleService {
}
async getByTransactionId(transactionId: string) {
return this.repo.findOne({ where: { transactionId } });
return this.repo.findByTransactionId(transactionId);
}
}

View File

@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
// Domain Entities
// ─── Domain Entities ───
import { AmlAlert } from './domain/entities/aml-alert.entity';
import { OfacScreening } from './domain/entities/ofac-screening.entity';
import { TravelRuleRecord } from './domain/entities/travel-rule-record.entity';
@ -12,19 +12,41 @@ import { Dispute } from './domain/entities/dispute.entity';
import { AuditLog } from './domain/entities/audit-log.entity';
import { InsuranceClaim } from './domain/entities/insurance-claim.entity';
// Core Services
// ─── Domain Repository Interfaces (Symbols) ───
import { AML_ALERT_REPOSITORY } from './domain/repositories/aml-alert.repository.interface';
import { OFAC_SCREENING_REPOSITORY } from './domain/repositories/ofac-screening.repository.interface';
import { SAR_REPORT_REPOSITORY } from './domain/repositories/sar-report.repository.interface';
import { TRAVEL_RULE_REPOSITORY } from './domain/repositories/travel-rule.repository.interface';
import { DISPUTE_REPOSITORY } from './domain/repositories/dispute.repository.interface';
import { INSURANCE_CLAIM_REPOSITORY } from './domain/repositories/insurance-claim.repository.interface';
import { AUDIT_LOG_REPOSITORY } from './domain/repositories/audit-log.repository.interface';
// ─── Infrastructure Persistence (Concrete Implementations) ───
import { AmlAlertRepository } from './infrastructure/persistence/aml-alert.repository';
import { OfacScreeningRepository } from './infrastructure/persistence/ofac-screening.repository';
import { SarReportRepository } from './infrastructure/persistence/sar-report.repository';
import { TravelRuleRepository } from './infrastructure/persistence/travel-rule.repository';
import { DisputeRepository } from './infrastructure/persistence/dispute.repository';
import { InsuranceClaimRepository } from './infrastructure/persistence/insurance-claim.repository';
import { AuditLogRepository } from './infrastructure/persistence/audit-log.repository';
// ─── Domain Ports ───
import { AUDIT_LOGGER_SERVICE } from './domain/ports/audit-logger.interface';
// ─── Infrastructure Services ───
import { AuditLoggerService } from './infrastructure/services/audit-logger.service';
// ─── Application Services ───
import { AmlService } from './application/services/aml.service';
import { OfacService } from './application/services/ofac.service';
import { TravelRuleService } from './application/services/travel-rule.service';
import { SarService } from './application/services/sar.service';
// Admin Services
import { AdminRiskService } from './application/services/admin-risk.service';
import { AdminComplianceService } from './application/services/admin-compliance.service';
import { AdminDisputeService } from './application/services/admin-dispute.service';
import { AdminInsuranceService } from './application/services/admin-insurance.service';
// Controllers
// ─── Interface Controllers ───
import { ComplianceController } from './interface/http/controllers/compliance.controller';
import { AdminRiskController } from './interface/http/controllers/admin-risk.controller';
import { AdminComplianceController } from './interface/http/controllers/admin-compliance.controller';
@ -53,18 +75,30 @@ import { AdminInsuranceController } from './interface/http/controllers/admin-ins
AdminInsuranceController,
],
providers: [
// Core services
// ─── Repository DI bindings (interface → implementation) ───
{ provide: AML_ALERT_REPOSITORY, useClass: AmlAlertRepository },
{ provide: OFAC_SCREENING_REPOSITORY, useClass: OfacScreeningRepository },
{ provide: SAR_REPORT_REPOSITORY, useClass: SarReportRepository },
{ provide: TRAVEL_RULE_REPOSITORY, useClass: TravelRuleRepository },
{ provide: DISPUTE_REPOSITORY, useClass: DisputeRepository },
{ provide: INSURANCE_CLAIM_REPOSITORY, useClass: InsuranceClaimRepository },
{ provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository },
// ─── Infrastructure services ───
{ provide: AUDIT_LOGGER_SERVICE, useClass: AuditLoggerService },
// ─── Application services ───
AmlService,
OfacService,
TravelRuleService,
SarService,
// Admin services
AdminRiskService,
AdminComplianceService,
AdminDisputeService,
AdminInsuranceService,
],
exports: [
// Export application services for potential use by other modules
AmlService,
OfacService,
TravelRuleService,
@ -73,6 +107,9 @@ import { AdminInsuranceController } from './interface/http/controllers/admin-ins
AdminComplianceService,
AdminDisputeService,
AdminInsuranceService,
// Export infrastructure service symbols for DI in other modules
AUDIT_LOGGER_SERVICE,
],
})
export class ComplianceModule {}

View File

@ -0,0 +1,65 @@
/**
* Domain Events for the Compliance bounded context.
*
* These events represent significant domain occurrences that other
* parts of the system (or external services via Kafka) may react to.
*/
export interface AmlAlertCreatedEvent {
alertId: string;
userId: string;
pattern: string;
riskScore: number;
description: string;
timestamp: string;
}
export interface OfacScreeningCompletedEvent {
screeningId: string;
userId: string;
screenedName: string;
isMatch: boolean;
matchScore: string;
result: string;
timestamp: string;
}
export interface SarReportFiledEvent {
reportId: string;
alertId: string;
userId: string;
reportType: string;
fincenReference: string | null;
timestamp: string;
}
export interface DisputeOpenedEvent {
disputeId: string;
orderId: string;
plaintiffId: string;
defendantId: string | null;
type: string;
amount: string;
timestamp: string;
}
export interface DisputeResolvedEvent {
disputeId: string;
orderId: string;
plaintiffId: string;
status: string;
resolution: string;
resolvedAt: string;
timestamp: string;
}
/**
* Compliance event topic constants for Kafka publishing.
*/
export const COMPLIANCE_EVENT_TOPICS = {
AML_ALERT_CREATED: 'compliance.aml-alert.created',
OFAC_SCREENING_COMPLETED: 'compliance.ofac-screening.completed',
SAR_REPORT_FILED: 'compliance.sar-report.filed',
DISPUTE_OPENED: 'compliance.dispute.opened',
DISPUTE_RESOLVED: 'compliance.dispute.resolved',
} as const;

View File

@ -0,0 +1,23 @@
/**
* Audit Logger domain port.
*
* Application services depend on this interface for recording audit trails.
* The concrete implementation lives in infrastructure/services/.
*/
export const AUDIT_LOGGER_SERVICE = Symbol('IAuditLoggerService');
export interface AuditLogParams {
adminId: string;
adminName: string;
action: string;
resource: string;
resourceId: string | null;
ipAddress?: string;
result?: string;
details?: Record<string, any> | null;
}
export interface IAuditLoggerService {
log(params: AuditLogParams): Promise<void>;
}

View File

@ -0,0 +1,38 @@
import { AmlAlert, AlertStatus, AmlPattern } from '../entities/aml-alert.entity';
export const AML_ALERT_REPOSITORY = Symbol('IAmlAlertRepository');
export interface IAmlAlertRepository {
create(data: Partial<AmlAlert>): AmlAlert;
save(alert: AmlAlert): Promise<AmlAlert>;
findById(id: string): Promise<AmlAlert | null>;
findAndCount(options: {
where?: Record<string, any>;
skip?: number;
take?: number;
order?: Record<string, 'ASC' | 'DESC'>;
}): Promise<[AmlAlert[], number]>;
find(options: {
where?: Record<string, any> | Record<string, any>[];
order?: Record<string, 'ASC' | 'DESC'>;
take?: number;
}): Promise<AmlAlert[]>;
count(options?: { where?: Record<string, any> | Record<string, any>[] }): Promise<number>;
update(id: string, data: Partial<AmlAlert>): Promise<void>;
/** Count alerts with risk_score >= threshold and not in resolved/dismissed status */
countHighRiskActive(threshold: number): Promise<number>;
/** Paginated query with optional status and pattern filters */
findPaginated(
page: number,
limit: number,
filters?: { status?: string; pattern?: string },
): Promise<[AmlAlert[], number]>;
/** Find alerts with risk_score >= threshold, ordered by score desc */
findSuspicious(page: number, limit: number, threshold: number): Promise<[AmlAlert[], number]>;
/** Count high-risk alerts (risk_score >= threshold) */
countHighRisk(threshold: number): Promise<number>;
}

View File

@ -0,0 +1,21 @@
import { AuditLog } from '../entities/audit-log.entity';
export const AUDIT_LOG_REPOSITORY = Symbol('IAuditLogRepository');
export interface IAuditLogRepository {
create(data: Partial<AuditLog>): AuditLog;
save(log: AuditLog): Promise<AuditLog>;
/** Paginated list with optional filters */
findPaginated(
page: number,
limit: number,
filters?: {
action?: string;
adminId?: string;
resource?: string;
startDate?: string;
endDate?: string;
},
): Promise<[AuditLog[], number]>;
}

View File

@ -0,0 +1,16 @@
import { Dispute } from '../entities/dispute.entity';
export const DISPUTE_REPOSITORY = Symbol('IDisputeRepository');
export interface IDisputeRepository {
create(data: Partial<Dispute>): Dispute;
save(dispute: Dispute): Promise<Dispute>;
findById(id: string): Promise<Dispute | null>;
/** Paginated list with optional status and type filters */
findPaginated(
page: number,
limit: number,
filters?: { status?: string; type?: string },
): Promise<[Dispute[], number]>;
}

View File

@ -0,0 +1,20 @@
import { InsuranceClaim, ClaimStatus } from '../entities/insurance-claim.entity';
export const INSURANCE_CLAIM_REPOSITORY = Symbol('IInsuranceClaimRepository');
export interface IInsuranceClaimRepository {
create(data: Partial<InsuranceClaim>): InsuranceClaim;
save(claim: InsuranceClaim): Promise<InsuranceClaim>;
findById(id: string): Promise<InsuranceClaim | null>;
count(options?: { where?: Record<string, any> }): Promise<number>;
/** Paginated list with optional status filter */
findPaginated(
page: number,
limit: number,
filters?: { status?: string },
): Promise<[InsuranceClaim[], number]>;
/** Sum amount for a given claim status */
sumAmountByStatus(status: ClaimStatus): Promise<string>;
}

View File

@ -0,0 +1,13 @@
import { OfacScreening } from '../entities/ofac-screening.entity';
export const OFAC_SCREENING_REPOSITORY = Symbol('IOfacScreeningRepository');
export interface IOfacScreeningRepository {
create(data: Partial<OfacScreening>): OfacScreening;
save(screening: OfacScreening): Promise<OfacScreening>;
findByUserId(userId: string): Promise<OfacScreening[]>;
count(options?: { where?: Record<string, any> }): Promise<number>;
/** Find OFAC matches (blacklisted), paginated */
findMatchesPaginated(page: number, limit: number): Promise<[OfacScreening[], number]>;
}

View File

@ -0,0 +1,18 @@
import { SarReport } from '../entities/sar-report.entity';
export const SAR_REPORT_REPOSITORY = Symbol('ISarReportRepository');
export interface ISarReportRepository {
create(data: Partial<SarReport>): SarReport;
save(report: SarReport): Promise<SarReport>;
findById(id: string): Promise<SarReport | null>;
update(id: string, data: Partial<SarReport>): Promise<void>;
count(options?: { where?: Record<string, any> }): Promise<number>;
/** Paginated list with optional status filter */
findPaginated(
page: number,
limit: number,
filters?: { status?: string },
): Promise<[SarReport[], number]>;
}

View File

@ -0,0 +1,10 @@
import { TravelRuleRecord } from '../entities/travel-rule-record.entity';
export const TRAVEL_RULE_REPOSITORY = Symbol('ITravelRuleRepository');
export interface ITravelRuleRepository {
create(data: Partial<TravelRuleRecord>): TravelRuleRecord;
save(record: TravelRuleRecord): Promise<TravelRuleRecord>;
findByTransactionId(transactionId: string): Promise<TravelRuleRecord | null>;
count(): Promise<number>;
}

View File

@ -0,0 +1,100 @@
/**
* Value Object: AlertSeverity
*
* Encapsulates the severity level of a compliance alert.
* Immutable after creation via static factory methods.
*/
export enum SeverityLevel {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical',
}
export class AlertSeverity {
private static readonly VALID_LEVELS = Object.values(SeverityLevel);
private constructor(private readonly level: SeverityLevel) {}
/**
* Create an AlertSeverity from a string level.
* @throws Error if level is not a valid severity level
*/
static create(level: string): AlertSeverity {
const normalized = level.toLowerCase() as SeverityLevel;
if (!AlertSeverity.VALID_LEVELS.includes(normalized)) {
throw new Error(
`Invalid severity level: "${level}". Must be one of: ${AlertSeverity.VALID_LEVELS.join(', ')}`,
);
}
return new AlertSeverity(normalized);
}
/** Create a LOW severity */
static low(): AlertSeverity {
return new AlertSeverity(SeverityLevel.LOW);
}
/** Create a MEDIUM severity */
static medium(): AlertSeverity {
return new AlertSeverity(SeverityLevel.MEDIUM);
}
/** Create a HIGH severity */
static high(): AlertSeverity {
return new AlertSeverity(SeverityLevel.HIGH);
}
/** Create a CRITICAL severity */
static critical(): AlertSeverity {
return new AlertSeverity(SeverityLevel.CRITICAL);
}
/**
* Derive severity from a risk score.
* - 0-39: LOW
* - 40-69: MEDIUM
* - 70-89: HIGH
* - 90-100: CRITICAL
*/
static fromRiskScore(score: number): AlertSeverity {
if (score >= 90) return AlertSeverity.critical();
if (score >= 70) return AlertSeverity.high();
if (score >= 40) return AlertSeverity.medium();
return AlertSeverity.low();
}
get value(): SeverityLevel {
return this.level;
}
toString(): string {
return this.level;
}
/** Numeric priority (higher = more severe) for sorting/comparison */
get priority(): number {
switch (this.level) {
case SeverityLevel.CRITICAL:
return 4;
case SeverityLevel.HIGH:
return 3;
case SeverityLevel.MEDIUM:
return 2;
case SeverityLevel.LOW:
return 1;
}
}
isHigherThan(other: AlertSeverity): boolean {
return this.priority > other.priority;
}
isAtLeast(level: SeverityLevel): boolean {
return this.priority >= AlertSeverity.create(level).priority;
}
equals(other: AlertSeverity): boolean {
return this.level === other.level;
}
}

View File

@ -0,0 +1,77 @@
/**
* Value Object: RiskScore
*
* Encapsulates a risk score value (0-100) with domain validation.
* Immutable after creation via static factory methods.
*/
export class RiskScore {
private static readonly MIN = 0;
private static readonly MAX = 100;
private static readonly HIGH_THRESHOLD = 70;
private static readonly MEDIUM_THRESHOLD = 40;
private constructor(private readonly score: number) {}
/**
* Create a RiskScore from a numeric value.
* @throws Error if score is out of range [0, 100]
*/
static create(score: number): RiskScore {
if (score < RiskScore.MIN || score > RiskScore.MAX) {
throw new Error(
`Risk score must be between ${RiskScore.MIN} and ${RiskScore.MAX}, got ${score}`,
);
}
if (!Number.isFinite(score)) {
throw new Error('Risk score must be a finite number');
}
return new RiskScore(score);
}
/**
* Reconstitute a RiskScore from a stored string value (e.g. from DB numeric column).
*/
static fromString(value: string): RiskScore {
const parsed = parseFloat(value);
if (isNaN(parsed)) {
throw new Error(`Cannot parse risk score from string: "${value}"`);
}
return RiskScore.create(parsed);
}
/** Get the numeric value */
get value(): number {
return this.score;
}
/** Get the string representation (for DB storage) */
toString(): string {
return String(this.score);
}
/** Whether this risk score is considered high risk */
isHighRisk(): boolean {
return this.score >= RiskScore.HIGH_THRESHOLD;
}
/** Whether this risk score is considered medium risk */
isMediumRisk(): boolean {
return this.score >= RiskScore.MEDIUM_THRESHOLD && this.score < RiskScore.HIGH_THRESHOLD;
}
/** Whether this risk score is considered low risk */
isLowRisk(): boolean {
return this.score < RiskScore.MEDIUM_THRESHOLD;
}
/** Get the risk level string */
get level(): 'high' | 'medium' | 'low' {
if (this.isHighRisk()) return 'high';
if (this.isMediumRisk()) return 'medium';
return 'low';
}
equals(other: RiskScore): boolean {
return this.score === other.score;
}
}

View File

@ -0,0 +1,103 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AmlAlert, AlertStatus } from '../../domain/entities/aml-alert.entity';
import { IAmlAlertRepository } from '../../domain/repositories/aml-alert.repository.interface';
@Injectable()
export class AmlAlertRepository implements IAmlAlertRepository {
constructor(
@InjectRepository(AmlAlert)
private readonly repo: Repository<AmlAlert>,
) {}
create(data: Partial<AmlAlert>): AmlAlert {
return this.repo.create(data);
}
async save(alert: AmlAlert): Promise<AmlAlert> {
return this.repo.save(alert);
}
async findById(id: string): Promise<AmlAlert | null> {
return this.repo.findOne({ where: { id } });
}
async findAndCount(options: {
where?: Record<string, any>;
skip?: number;
take?: number;
order?: Record<string, 'ASC' | 'DESC'>;
}): Promise<[AmlAlert[], number]> {
return this.repo.findAndCount(options);
}
async find(options: {
where?: Record<string, any> | Record<string, any>[];
order?: Record<string, 'ASC' | 'DESC'>;
take?: number;
}): Promise<AmlAlert[]> {
return this.repo.find(options);
}
async count(options?: { where?: Record<string, any> | Record<string, any>[] }): Promise<number> {
return this.repo.count(options);
}
async update(id: string, data: Partial<AmlAlert>): Promise<void> {
await this.repo.update(id, data);
}
async countHighRiskActive(threshold: number): Promise<number> {
return this.repo
.createQueryBuilder('a')
.where('a.risk_score >= :threshold', { threshold })
.andWhere('a.status != :resolved', { resolved: AlertStatus.RESOLVED })
.andWhere('a.status != :dismissed', { dismissed: AlertStatus.DISMISSED })
.getCount();
}
async findPaginated(
page: number,
limit: number,
filters?: { status?: string; pattern?: string },
): Promise<[AmlAlert[], number]> {
const qb = this.repo.createQueryBuilder('alert');
if (filters?.status) {
qb.andWhere('alert.status = :status', { status: filters.status });
}
if (filters?.pattern) {
qb.andWhere('alert.pattern = :pattern', { pattern: filters.pattern });
}
qb.orderBy('alert.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
return qb.getManyAndCount();
}
async findSuspicious(
page: number,
limit: number,
threshold: number,
): Promise<[AmlAlert[], number]> {
const qb = this.repo
.createQueryBuilder('alert')
.where('alert.risk_score >= :threshold', { threshold })
.orderBy('alert.risk_score', 'DESC')
.addOrderBy('alert.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
return qb.getManyAndCount();
}
async countHighRisk(threshold: number): Promise<number> {
return this.repo
.createQueryBuilder('a')
.where('a.risk_score >= :score', { score: threshold })
.getCount();
}
}

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditLog } from '../../domain/entities/audit-log.entity';
import { IAuditLogRepository } from '../../domain/repositories/audit-log.repository.interface';
@Injectable()
export class AuditLogRepository implements IAuditLogRepository {
constructor(
@InjectRepository(AuditLog)
private readonly repo: Repository<AuditLog>,
) {}
create(data: Partial<AuditLog>): AuditLog {
return this.repo.create(data);
}
async save(log: AuditLog): Promise<AuditLog> {
return this.repo.save(log);
}
async findPaginated(
page: number,
limit: number,
filters?: {
action?: string;
adminId?: string;
resource?: string;
startDate?: string;
endDate?: string;
},
): Promise<[AuditLog[], number]> {
const qb = this.repo.createQueryBuilder('log');
if (filters?.action) {
qb.andWhere('log.action = :action', { action: filters.action });
}
if (filters?.adminId) {
qb.andWhere('log.admin_id = :adminId', { adminId: filters.adminId });
}
if (filters?.resource) {
qb.andWhere('log.resource = :resource', { resource: filters.resource });
}
if (filters?.startDate) {
qb.andWhere('log.created_at >= :startDate', { startDate: filters.startDate });
}
if (filters?.endDate) {
qb.andWhere('log.created_at <= :endDate', { endDate: filters.endDate });
}
qb.orderBy('log.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
return qb.getManyAndCount();
}
}

View File

@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Dispute } from '../../domain/entities/dispute.entity';
import { IDisputeRepository } from '../../domain/repositories/dispute.repository.interface';
@Injectable()
export class DisputeRepository implements IDisputeRepository {
constructor(
@InjectRepository(Dispute)
private readonly repo: Repository<Dispute>,
) {}
create(data: Partial<Dispute>): Dispute {
return this.repo.create(data);
}
async save(dispute: Dispute): Promise<Dispute> {
return this.repo.save(dispute);
}
async findById(id: string): Promise<Dispute | null> {
return this.repo.findOne({ where: { id } });
}
async findPaginated(
page: number,
limit: number,
filters?: { status?: string; type?: string },
): Promise<[Dispute[], number]> {
const qb = this.repo.createQueryBuilder('dispute');
if (filters?.status) {
qb.andWhere('dispute.status = :status', { status: filters.status });
}
if (filters?.type) {
qb.andWhere('dispute.type = :type', { type: filters.type });
}
qb.orderBy('dispute.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
return qb.getManyAndCount();
}
}

View File

@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InsuranceClaim, ClaimStatus } from '../../domain/entities/insurance-claim.entity';
import { IInsuranceClaimRepository } from '../../domain/repositories/insurance-claim.repository.interface';
@Injectable()
export class InsuranceClaimRepository implements IInsuranceClaimRepository {
constructor(
@InjectRepository(InsuranceClaim)
private readonly repo: Repository<InsuranceClaim>,
) {}
create(data: Partial<InsuranceClaim>): InsuranceClaim {
return this.repo.create(data);
}
async save(claim: InsuranceClaim): Promise<InsuranceClaim> {
return this.repo.save(claim);
}
async findById(id: string): Promise<InsuranceClaim | null> {
return this.repo.findOne({ where: { id } });
}
async count(options?: { where?: Record<string, any> }): Promise<number> {
return this.repo.count(options);
}
async findPaginated(
page: number,
limit: number,
filters?: { status?: string },
): Promise<[InsuranceClaim[], number]> {
const qb = this.repo.createQueryBuilder('claim');
if (filters?.status) {
qb.andWhere('claim.status = :status', { status: filters.status });
}
qb.orderBy('claim.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
return qb.getManyAndCount();
}
async sumAmountByStatus(status: ClaimStatus): Promise<string> {
const result = await this.repo
.createQueryBuilder('claim')
.select('COALESCE(SUM(claim.amount), 0)', 'total')
.where('claim.status = :status', { status })
.getRawOne();
return result?.total || '0';
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OfacScreening } from '../../domain/entities/ofac-screening.entity';
import { IOfacScreeningRepository } from '../../domain/repositories/ofac-screening.repository.interface';
@Injectable()
export class OfacScreeningRepository implements IOfacScreeningRepository {
constructor(
@InjectRepository(OfacScreening)
private readonly repo: Repository<OfacScreening>,
) {}
create(data: Partial<OfacScreening>): OfacScreening {
return this.repo.create(data);
}
async save(screening: OfacScreening): Promise<OfacScreening> {
return this.repo.save(screening);
}
async findByUserId(userId: string): Promise<OfacScreening[]> {
return this.repo.find({ where: { userId }, order: { screenedAt: 'DESC' } });
}
async count(options?: { where?: Record<string, any> }): Promise<number> {
return this.repo.count(options);
}
async findMatchesPaginated(page: number, limit: number): Promise<[OfacScreening[], number]> {
return this.repo.findAndCount({
where: { isMatch: true },
order: { screenedAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
}
}

View File

@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SarReport } from '../../domain/entities/sar-report.entity';
import { ISarReportRepository } from '../../domain/repositories/sar-report.repository.interface';
@Injectable()
export class SarReportRepository implements ISarReportRepository {
constructor(
@InjectRepository(SarReport)
private readonly repo: Repository<SarReport>,
) {}
create(data: Partial<SarReport>): SarReport {
return this.repo.create(data);
}
async save(report: SarReport): Promise<SarReport> {
return this.repo.save(report);
}
async findById(id: string): Promise<SarReport | null> {
return this.repo.findOne({ where: { id } });
}
async update(id: string, data: Partial<SarReport>): Promise<void> {
await this.repo.update(id, data);
}
async count(options?: { where?: Record<string, any> }): Promise<number> {
return this.repo.count(options);
}
async findPaginated(
page: number,
limit: number,
filters?: { status?: string },
): Promise<[SarReport[], number]> {
const qb = this.repo.createQueryBuilder('sar');
if (filters?.status) {
qb.andWhere('sar.filing_status = :status', { status: filters.status });
}
qb.orderBy('sar.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
return qb.getManyAndCount();
}
}

Some files were not shown because too many files have changed in this diff Show More