From 0c8fe58a30206b8e06e2d1d7f13e7d0ceb6e9f28 Mon Sep 17 00:00:00 2001 From: Whiznificent Date: Sun, 28 Jun 2026 13:40:21 +0100 Subject: [PATCH 1/4] feat: Add payment reconciliation scheduled job - Add PAYMENT_RECONCILIATION_MISMATCH audit action - Create ReconciliationService to compare local payments with provider transactions - Create ReconciliationTask scheduled daily at 02:00 UTC - Add admin endpoint GET /payments/reconciliation/report - Update PaymentsModule and AppModule to include reconciliation components Closes #856 --- src/app.module.ts | 2 + src/audit-log/enums/audit-action.enum.ts | 2 + src/payments/payments.module.ts | 16 +- .../reconciliation.controller.ts | 61 ++++ .../reconciliation/reconciliation.service.ts | 288 ++++++++++++++++++ .../reconciliation/reconciliation.task.ts | 37 +++ 6 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 src/payments/reconciliation/reconciliation.controller.ts create mode 100644 src/payments/reconciliation/reconciliation.service.ts create mode 100644 src/payments/reconciliation/reconciliation.task.ts diff --git a/src/app.module.ts b/src/app.module.ts index 81dd098b..5d474ea5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,7 @@ import { GlobalExceptionFilter } from './common/interceptors/global-exception.fi import { DeepLinkModule } from './deep-link/deep-link.module'; import { InvoicesModule } from './payments/invoices/invoices.module'; import { ReportingModule } from './payments/reporting/reporting.module'; +import { PaymentsModule } from './payments/payments.module'; import { HealthModule } from './health/health.module'; // ✅ keep BOTH modules @@ -53,6 +54,7 @@ const featureFlags = loadFeatureFlags(); DeepLinkModule, InvoicesModule, ReportingModule, + PaymentsModule, HealthModule, // ✅ always include read replicas (or wrap if needed) diff --git a/src/audit-log/enums/audit-action.enum.ts b/src/audit-log/enums/audit-action.enum.ts index b0227f37..d0915de5 100644 --- a/src/audit-log/enums/audit-action.enum.ts +++ b/src/audit-log/enums/audit-action.enum.ts @@ -48,6 +48,8 @@ export enum AuditAction { DATA_RETENTION_APPLIED = 'DATA_RETENTION_APPLIED', AUDIT_LOG_EXPORTED = 'AUDIT_LOG_EXPORTED', REPORT_GENERATED = 'REPORT_GENERATED', + // Payment reconciliation + PAYMENT_RECONCILIATION_MISMATCH = 'PAYMENT_RECONCILIATION_MISMATCH', } export enum AuditSeverity { INFO = 'INFO', diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index c09a0e76..6b54ce13 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -1,17 +1,25 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CurrencyModule } from '../currency/currency.module'; +import { AuditLogModule } from '../audit-log/audit-log.module'; import { Payment } from './entities/payment.entity'; import { Subscription } from './entities/subscription.entity'; import { Invoice } from './entities/invoice.entity'; import { Refund } from './entities/refund.entity'; import { PricingService } from './services/pricing.service'; import { PricingController } from './controllers/pricing.controller'; +import { ReconciliationService } from './reconciliation/reconciliation.service'; +import { ReconciliationTask } from './reconciliation/reconciliation.task'; +import { ReconciliationController } from './reconciliation/reconciliation.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Payment, Subscription, Invoice, Refund]), CurrencyModule], - providers: [PricingService], - controllers: [PricingController], - exports: [PricingService, CurrencyModule], + imports: [ + TypeOrmModule.forFeature([Payment, Subscription, Invoice, Refund]), + CurrencyModule, + AuditLogModule, + ], + providers: [PricingService, ReconciliationService, ReconciliationTask], + controllers: [PricingController, ReconciliationController], + exports: [PricingService, CurrencyModule, ReconciliationService], }) export class PaymentsModule {} diff --git a/src/payments/reconciliation/reconciliation.controller.ts b/src/payments/reconciliation/reconciliation.controller.ts new file mode 100644 index 00000000..cb2eae8b --- /dev/null +++ b/src/payments/reconciliation/reconciliation.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { ReconciliationService, ReconciliationReport } from './reconciliation.service'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; + +/** + * Controller for payment reconciliation endpoints. + * Provides admin-only access to reconciliation reports. + */ +@ApiTags('Payments - Reconciliation') +@ApiBearerAuth() +@Controller('payments/reconciliation') +@UseGuards(RolesGuard) +export class ReconciliationController { + constructor(private readonly reconciliationService: ReconciliationService) {} + + /** + * Get the last reconciliation report + * GET /payments/reconciliation/report + */ + @Get('report') + @Roles('admin') + @ApiOperation({ + summary: 'Get last reconciliation report', + description: 'Returns the results of the most recent payment reconciliation run. Admin-only endpoint.', + }) + @ApiResponse({ + status: 200, + description: 'Reconciliation report retrieved successfully', + schema: { + type: 'object', + properties: { + runAt: { type: 'string', format: 'date-time' }, + startDate: { type: 'string', format: 'date-time' }, + endDate: { type: 'string', format: 'date-time' }, + totalProviderTransactions: { type: 'number' }, + totalLocalPayments: { type: 'number' }, + matchedTransactions: { type: 'number' }, + unmatchedProviderTransactions: { type: 'array', items: { type: 'object' } }, + unmatchedLocalPayments: { type: 'array', items: { type: 'object' } }, + mismatches: { type: 'array', items: { type: 'object' } }, + }, + }, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - authentication required', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - admin role required', + }) + @ApiResponse({ + status: 404, + description: 'No reconciliation report available', + }) + getLastReport(): ReconciliationReport | null { + return this.reconciliationService.getLastReport(); + } +} diff --git a/src/payments/reconciliation/reconciliation.service.ts b/src/payments/reconciliation/reconciliation.service.ts new file mode 100644 index 00000000..62ea8ce2 --- /dev/null +++ b/src/payments/reconciliation/reconciliation.service.ts @@ -0,0 +1,288 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Payment } from '../entities/payment.entity'; +import { AuditLogService } from '../../audit-log/audit-log.service'; +import { AuditAction, AuditSeverity, AuditCategory } from '../../audit-log/enums/audit-action.enum'; + +export interface ProviderTransaction { + id: string; + amount: number; + currency: string; + status: string; + createdAt: Date; + metadata?: Record; +} + +export interface ReconciliationReport { + runAt: Date; + startDate: Date; + endDate: Date; + totalProviderTransactions: number; + totalLocalPayments: number; + matchedTransactions: number; + unmatchedProviderTransactions: ProviderTransaction[]; + unmatchedLocalPayments: Payment[]; + mismatches: Array<{ + type: 'amount_mismatch' | 'status_mismatch' | 'currency_mismatch'; + providerTransaction: ProviderTransaction; + localPayment: Payment; + details: string; + }>; +} + +export interface ReconciliationResult { + success: boolean; + report: ReconciliationReport; + error?: string; +} + +@Injectable() +export class ReconciliationService { + private readonly logger = new Logger(ReconciliationService.name); + private lastReport: ReconciliationReport | null = null; + + constructor( + @InjectRepository(Payment) + private readonly paymentRepository: Repository, + private readonly auditLogService: AuditLogService, + ) {} + + /** + * Run reconciliation for the previous day + */ + async runDailyReconciliation(): Promise { + this.logger.log('Starting daily payment reconciliation...'); + + const endDate = new Date(); + endDate.setUTCHours(0, 0, 0, 0); + const startDate = new Date(endDate); + startDate.setUTCDate(startDate.getUTCDate() - 1); + + try { + const report = await this.reconcileDateRange(startDate, endDate); + this.lastReport = report; + + // Log summary to audit log + await this.auditLogService.log({ + action: AuditAction.REPORT_GENERATED, + userId: null, + userEmail: null, + entityType: 'PaymentReconciliation', + entityId: report.runAt.toISOString(), + ipAddress: 'system', + userAgent: 'reconciliation-service', + metadata: { + reconciliationType: 'daily', + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + totalProviderTransactions: report.totalProviderTransactions, + totalLocalPayments: report.totalLocalPayments, + matchedTransactions: report.matchedTransactions, + unmatchedCount: report.unmatchedProviderTransactions.length + report.unmatchedLocalPayments.length, + mismatchCount: report.mismatches.length, + }, + severity: report.mismatches.length > 0 ? AuditSeverity.WARNING : AuditSeverity.INFO, + category: AuditCategory.COMPLIANCE, + }); + + // Log individual mismatches + for (const mismatch of report.mismatches) { + await this.auditLogService.log({ + action: AuditAction.PAYMENT_RECONCILIATION_MISMATCH, + userId: null, + userEmail: null, + entityType: 'Payment', + entityId: mismatch.localPayment.id, + ipAddress: 'system', + userAgent: 'reconciliation-service', + metadata: { + mismatchType: mismatch.type, + providerTransactionId: mismatch.providerTransaction.id, + localPaymentId: mismatch.localPayment.id, + providerAmount: mismatch.providerTransaction.amount, + localAmount: mismatch.localPayment.amount, + providerStatus: mismatch.providerTransaction.status, + localStatus: mismatch.localPayment.status, + providerCurrency: mismatch.providerTransaction.currency, + localCurrency: mismatch.localPayment.currency, + details: mismatch.details, + }, + severity: AuditSeverity.ERROR, + category: AuditCategory.COMPLIANCE, + }); + } + + this.logger.log( + `Reconciliation completed. Matched: ${report.matchedTransactions}, Unmatched: ${report.unmatchedProviderTransactions.length + report.unmatchedLocalPayments.length}, Mismatches: ${report.mismatches.length}`, + ); + + return { success: true, report }; + } catch (error) { + this.logger.error('Reconciliation failed:', error); + return { + success: false, + report: { + runAt: new Date(), + startDate, + endDate, + totalProviderTransactions: 0, + totalLocalPayments: 0, + matchedTransactions: 0, + unmatchedProviderTransactions: [], + unmatchedLocalPayments: [], + mismatches: [], + }, + error: error.message, + }; + } + } + + /** + * Reconcile payments for a specific date range + */ + private async reconcileDateRange(startDate: Date, endDate: Date): Promise { + // Fetch transactions from payment provider (mock implementation) + const providerTransactions = await this.fetchProviderTransactions(startDate, endDate); + + // Fetch local payments for the same period + const localPayments = await this.paymentRepository.find({ + where: { + createdAt: { + $gte: startDate, + $lt: endDate, + } as any, + }, + }); + + // Compare transactions + const matchedIds = new Set(); + const unmatchedProviderTransactions: ProviderTransaction[] = []; + const unmatchedLocalPayments: Payment[] = []; + const mismatches: ReconciliationReport['mismatches'] = []; + + // Build a map of local payments by provider transaction ID + const localPaymentsByProviderId = new Map(); + for (const payment of localPayments) { + if (payment.providerPaymentId) { + localPaymentsByProviderId.set(payment.providerPaymentId, payment); + } + } + + // Check each provider transaction + for (const providerTx of providerTransactions) { + const localPayment = localPaymentsByProviderId.get(providerTx.id); + + if (!localPayment) { + unmatchedProviderTransactions.push(providerTx); + continue; + } + + matchedIds.add(providerTx.id); + + // Check for mismatches + const mismatch = this.detectMismatch(providerTx, localPayment); + if (mismatch) { + mismatches.push(mismatch); + } + } + + // Find local payments without matching provider transactions + for (const payment of localPayments) { + if (payment.providerPaymentId && !matchedIds.has(payment.providerPaymentId)) { + unmatchedLocalPayments.push(payment); + } + } + + return { + runAt: new Date(), + startDate, + endDate, + totalProviderTransactions: providerTransactions.length, + totalLocalPayments: localPayments.length, + matchedTransactions: matchedIds.size, + unmatchedProviderTransactions, + unmatchedLocalPayments, + mismatches, + }; + } + + /** + * Fetch transactions from payment provider for the given date range + * This is a mock implementation - in production, this would call the actual provider API (Stripe, PayPal, etc.) + */ + private async fetchProviderTransactions(startDate: Date, endDate: Date): Promise { + // TODO: Implement actual provider API call + // For now, return empty array as this needs to be integrated with the actual payment provider + // Example implementation for Stripe: + // const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + // const charges = await stripe.charges.list({ + // created: { gte: Math.floor(startDate.getTime() / 1000), lt: Math.floor(endDate.getTime() / 1000) }, + // limit: 100, + // }); + // return charges.data.map(charge => ({ + // id: charge.id, + // amount: charge.amount / 100, + // currency: charge.currency.toUpperCase(), + // status: charge.status, + // createdAt: new Date(charge.created * 1000), + // metadata: charge.metadata, + // })); + + this.logger.warn( + 'Provider transaction fetch not implemented - using mock implementation. Integrate with actual payment provider API.', + ); + return []; + } + + /** + * Detect mismatches between provider transaction and local payment + */ + private detectMismatch(providerTx: ProviderTransaction, localPayment: Payment): ReconciliationReport['mismatches'][0] | null { + // Check amount mismatch + if (Math.abs(providerTx.amount - Number(localPayment.amount)) > 0.01) { + return { + type: 'amount_mismatch', + providerTransaction: providerTx, + localPayment, + details: `Amount mismatch: provider=${providerTx.amount}, local=${localPayment.amount}`, + }; + } + + // Check currency mismatch + if (providerTx.currency !== localPayment.currency) { + return { + type: 'currency_mismatch', + providerTransaction: providerTx, + localPayment, + details: `Currency mismatch: provider=${providerTx.currency}, local=${localPayment.currency}`, + }; + } + + // Check status mismatch (mapping provider status to local status) + const statusMap: Record = { + succeeded: 'completed', + pending: 'pending', + failed: 'failed', + refunded: 'refunded', + }; + const expectedLocalStatus = statusMap[providerTx.status] || providerTx.status; + if (expectedLocalStatus !== localPayment.status) { + return { + type: 'status_mismatch', + providerTransaction: providerTx, + localPayment, + details: `Status mismatch: provider=${providerTx.status}, local=${localPayment.status}`, + }; + } + + return null; + } + + /** + * Get the last reconciliation report + */ + getLastReport(): ReconciliationReport | null { + return this.lastReport; + } +} diff --git a/src/payments/reconciliation/reconciliation.task.ts b/src/payments/reconciliation/reconciliation.task.ts new file mode 100644 index 00000000..a60e78f8 --- /dev/null +++ b/src/payments/reconciliation/reconciliation.task.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ReconciliationService } from './reconciliation.service'; + +/** + * Scheduled task for daily payment reconciliation. + * Runs daily at 02:00 UTC to compare local payments with payment provider transactions. + */ +@Injectable() +export class ReconciliationTask { + private readonly logger = new Logger(ReconciliationTask.name); + + constructor(private readonly reconciliationService: ReconciliationService) {} + + /** + * Run daily reconciliation at 02:00 UTC + * Cron expression: 0 2 * * * (every day at 2:00 AM UTC) + */ + @Cron('0 2 * * *', { + timeZone: 'UTC', + }) + async handleDailyReconciliation(): Promise { + this.logger.log('Starting daily payment reconciliation job at 02:00 UTC...'); + try { + const result = await this.reconciliationService.runDailyReconciliation(); + if (result.success) { + this.logger.log( + `Daily reconciliation completed successfully. Matched: ${result.report.matchedTransactions}, Unmatched: ${result.report.unmatchedProviderTransactions.length + result.report.unmatchedLocalPayments.length}, Mismatches: ${result.report.mismatches.length}`, + ); + } else { + this.logger.error(`Daily reconciliation failed: ${result.error}`); + } + } catch (error) { + this.logger.error('Failed to run daily reconciliation:', error); + } + } +} From c94243459c1bb81101041ad8706a651b48ca1df2 Mon Sep 17 00:00:00 2001 From: Whiznificent Date: Tue, 30 Jun 2026 19:08:43 +0100 Subject: [PATCH 2/4] fix: add newline EOF to ci.yml and remove unused Inject imports --- .github/workflows/ci.yml | 2 +- src/workers/base/base.worker.ts | 2 +- src/workers/orchestration/worker-orchestration.service.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 100b37e1..15be520a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,4 +36,4 @@ jobs: run: pnpm run typecheck - name: Build application - run: pnpm run build \ No newline at end of file + run: pnpm run build diff --git a/src/workers/base/base.worker.ts b/src/workers/base/base.worker.ts index e4f58cec..70f16101 100644 --- a/src/workers/base/base.worker.ts +++ b/src/workers/base/base.worker.ts @@ -1,4 +1,4 @@ -import { Logger, Inject } from '@nestjs/common'; +import { Logger } from '@nestjs/common'; import { Job } from 'bull'; import Redis from 'ioredis'; import { getSharedRedisClient } from '../../config/cache.config'; diff --git a/src/workers/orchestration/worker-orchestration.service.ts b/src/workers/orchestration/worker-orchestration.service.ts index 91275f5d..59eb3c13 100644 --- a/src/workers/orchestration/worker-orchestration.service.ts +++ b/src/workers/orchestration/worker-orchestration.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Job } from 'bull'; import { BaseWorker } from '../base/base.worker'; From 003c4e5bd33caa56fb9cb2cce1cc244954426b91 Mon Sep 17 00:00:00 2001 From: Whiznificent Date: Tue, 30 Jun 2026 22:54:05 +0100 Subject: [PATCH 3/4] fix(ci): add missing validation steps to CI workflow - Add format check step (format:check) - Add unit tests step (test:ci) - Add E2E tests step (test:e2e) - Add PostgreSQL and Redis services for E2E tests - Configure environment variables for database and Redis connections --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15be520a..160f7c65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,31 @@ jobs: validate: runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: teachlink_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:6 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout code uses: actions/checkout@v4 @@ -32,8 +57,25 @@ jobs: - name: Run lint run: pnpm run lint:ci + - name: Check format + run: pnpm run format:check + - name: Check TypeScript errors run: pnpm run typecheck - name: Build application run: pnpm run build + + - name: Run unit tests + run: pnpm run test:ci + + - name: Run E2E tests + run: pnpm run test:e2e + env: + DATABASE_HOST: localhost + DATABASE_PORT: 5432 + DATABASE_USER: postgres + DATABASE_PASSWORD: postgres + DATABASE_NAME: teachlink_test + REDIS_HOST: localhost + REDIS_PORT: 6379 From f89f0b8833c1ca9ae55427133a8df2cd8acc9015 Mon Sep 17 00:00:00 2001 From: Whiznificent Date: Thu, 2 Jul 2026 15:08:08 +0000 Subject: [PATCH 4/4] fix: prettier formatting and unused vars in reconciliation files --- .../reconciliation/reconciliation.controller.ts | 3 ++- .../reconciliation/reconciliation.service.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/payments/reconciliation/reconciliation.controller.ts b/src/payments/reconciliation/reconciliation.controller.ts index cb2eae8b..bcfa8164 100644 --- a/src/payments/reconciliation/reconciliation.controller.ts +++ b/src/payments/reconciliation/reconciliation.controller.ts @@ -23,7 +23,8 @@ export class ReconciliationController { @Roles('admin') @ApiOperation({ summary: 'Get last reconciliation report', - description: 'Returns the results of the most recent payment reconciliation run. Admin-only endpoint.', + description: + 'Returns the results of the most recent payment reconciliation run. Admin-only endpoint.', }) @ApiResponse({ status: 200, diff --git a/src/payments/reconciliation/reconciliation.service.ts b/src/payments/reconciliation/reconciliation.service.ts index 62ea8ce2..e8a704bc 100644 --- a/src/payments/reconciliation/reconciliation.service.ts +++ b/src/payments/reconciliation/reconciliation.service.ts @@ -79,7 +79,8 @@ export class ReconciliationService { totalProviderTransactions: report.totalProviderTransactions, totalLocalPayments: report.totalLocalPayments, matchedTransactions: report.matchedTransactions, - unmatchedCount: report.unmatchedProviderTransactions.length + report.unmatchedLocalPayments.length, + unmatchedCount: + report.unmatchedProviderTransactions.length + report.unmatchedLocalPayments.length, mismatchCount: report.mismatches.length, }, severity: report.mismatches.length > 0 ? AuditSeverity.WARNING : AuditSeverity.INFO, @@ -211,7 +212,10 @@ export class ReconciliationService { * Fetch transactions from payment provider for the given date range * This is a mock implementation - in production, this would call the actual provider API (Stripe, PayPal, etc.) */ - private async fetchProviderTransactions(startDate: Date, endDate: Date): Promise { + private async fetchProviderTransactions( + _startDate: Date, + _endDate: Date, + ): Promise { // TODO: Implement actual provider API call // For now, return empty array as this needs to be integrated with the actual payment provider // Example implementation for Stripe: @@ -238,7 +242,10 @@ export class ReconciliationService { /** * Detect mismatches between provider transaction and local payment */ - private detectMismatch(providerTx: ProviderTransaction, localPayment: Payment): ReconciliationReport['mismatches'][0] | null { + private detectMismatch( + providerTx: ProviderTransaction, + localPayment: Payment, + ): ReconciliationReport['mismatches'][0] | null { // Check amount mismatch if (Math.abs(providerTx.amount - Number(localPayment.amount)) > 0.01) { return {