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); + } + } +}