From 48f4c5133b842251f5b35a16fbfe79d6bd2e6dc2 Mon Sep 17 00:00:00 2001 From: ShantelPeters Date: Wed, 22 Jul 2026 04:00:55 +0100 Subject: [PATCH] feat(compliance): compliance reporting engine (SAT/UIF) + alert SLA pipeline Closes #317 - Adds monthly aggregation (>=210 UMA threshold) & zero-report generation (compliance.service.ts) - Adds compliance_alerts table with 24h SLA deadline tracking and compliance_filings table (migration 20260721130000_compliance_reporting) - Adds append-only triggers on platform_risk_events, compliance_alerts, and compliance_filings - Exposes query/admin endpoints (/admin/compliance/alerts, /admin/compliance/filings, /admin/compliance/filings/trigger) - Adds 10-year retention policy documentation (RETENTION_POLICY.md) - Adds unit/integration test suite (compliance.test.ts) --- micopay/backend/package-lock.json | 1 + micopay/backend/package.json | 1 + micopay/backend/src/config.ts | 11 + micopay/backend/src/db/schema.ts | 10 + micopay/backend/src/index.ts | 3 + micopay/backend/src/routes/admin.ts | 78 ++++++ .../src/services/compliance.service.ts | 250 +++++++++++++++++ micopay/backend/src/tests/compliance.test.ts | 260 ++++++++++++++++++ ...260721130000_compliance_reporting.down.sql | 10 + ...20260721130000_compliance_reporting.up.sql | 51 ++++ micopay/sql/migrations/RETENTION_POLICY.md | 27 ++ 11 files changed, 702 insertions(+) create mode 100644 micopay/backend/src/services/compliance.service.ts create mode 100644 micopay/backend/src/tests/compliance.test.ts create mode 100644 micopay/sql/migrations/20260721130000_compliance_reporting.down.sql create mode 100644 micopay/sql/migrations/20260721130000_compliance_reporting.up.sql create mode 100644 micopay/sql/migrations/RETENTION_POLICY.md diff --git a/micopay/backend/package-lock.json b/micopay/backend/package-lock.json index 6249314..6063289 100644 --- a/micopay/backend/package-lock.json +++ b/micopay/backend/package-lock.json @@ -3203,6 +3203,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", diff --git a/micopay/backend/package.json b/micopay/backend/package.json index 635d7ff..6e6842a 100644 --- a/micopay/backend/package.json +++ b/micopay/backend/package.json @@ -12,6 +12,7 @@ "test:rate-limit": "node --import tsx src/tests/rateLimit.test.ts", "test:abuse": "node --import tsx src/tests/abuse.service.test.ts", "test:kyc-gate": "node --import tsx src/tests/kyc-gate.service.test.ts", + "test:compliance": "node --import tsx src/tests/compliance.test.ts", "test:security": "node --import tsx src/tests/security.test.ts" }, "dependencies": { diff --git a/micopay/backend/src/config.ts b/micopay/backend/src/config.ts index 73aee5a..645bd33 100644 --- a/micopay/backend/src/config.ts +++ b/micopay/backend/src/config.ts @@ -165,6 +165,17 @@ export const config = { kycLevelExpiryDays: parseInt(process.env.KYC_LEVEL_EXPIRY_DAYS || '365', 10), kycOperationThresholds: parseKycOperationThresholds(process.env.KYC_OPERATION_THRESHOLDS_JSON), + // LFPIORPI aviso (reporting) thresholds, in UMA — consumed by the compliance + // reporting engine (#317). Kept here so the verified 2026-07-21 values aren't lost. + // UMA 2026 = $117.31 MXN/day. + // - 210 UMA (~$24,635 MXN): aviso when a single operation reaches it. + // - 4 UMA (~$469 MXN): aviso when the commission WE charge reaches it — + // ⚠️ this is on the protocol fee, not the trade amount; model it when + // setting mainnet fee parameters. Source: portal SPPLD del SAT. + umaDailyMxn: parseFloat(process.env.UMA_DAILY_MXN || '117.31'), + kycAvisoThresholdUma: parseInt(process.env.KYC_AVISO_THRESHOLD_UMA || '210', 10), + kycCommissionAvisoThresholdUma: parseInt(process.env.KYC_COMMISSION_AVISO_THRESHOLD_UMA || '4', 10), + // CORS & Security corsAllowedOrigins: parseAllowedOrigins(process.env.CORS_ALLOWED_ORIGINS, process.env.NODE_ENV), nodeEnv: process.env.NODE_ENV || 'development', diff --git a/micopay/backend/src/db/schema.ts b/micopay/backend/src/db/schema.ts index 527b6b4..fbaf9d4 100644 --- a/micopay/backend/src/db/schema.ts +++ b/micopay/backend/src/db/schema.ts @@ -16,6 +16,8 @@ const mem: Record = { platform_risk_events: [], trade_messages: [], trade_disputes: [], + compliance_alerts: [], + compliance_filings: [], }; function memNow() { @@ -209,6 +211,10 @@ function memQuery(sql: string, params: any[] = []): any[] { const tableMatch = s.match(/UPDATE\s+(\w+)\s+SET\s+(.+?)\s+WHERE\s+(.+)$/i); if (!tableMatch) return []; const tableName = tableMatch[1].toLowerCase(); + + if (["platform_risk_events", "compliance_alerts", "compliance_filings"].includes(tableName)) { + throw new Error("Updates and deletions are not allowed on this table (append-only compliance data)."); + } const setStr = tableMatch[2]; const whereStr = tableMatch[3]; @@ -231,6 +237,10 @@ function memQuery(sql: string, params: any[] = []): any[] { const tableMatch = s.match(/DELETE FROM\s+(\w+)(?:\s+WHERE\s+(.+))?$/i); if (!tableMatch) return []; const tableName = tableMatch[1].toLowerCase(); + + if (["platform_risk_events", "compliance_alerts", "compliance_filings"].includes(tableName)) { + throw new Error("Updates and deletions are not allowed on this table (append-only compliance data)."); + } const whereStr = tableMatch[2]; if (!whereStr) { diff --git a/micopay/backend/src/index.ts b/micopay/backend/src/index.ts index e01145e..8a3e030 100644 --- a/micopay/backend/src/index.ts +++ b/micopay/backend/src/index.ts @@ -25,6 +25,7 @@ import { registerRequestId, toSupportCode } from './middleware/requestId.middlew import { createProductionListener } from './services/event-listener.service.js'; import type { EscrowEventListener } from './services/event-listener.service.js'; import { sweepPendingRefunds } from './services/trade.service.js'; +import { startComplianceJob, stopComplianceJob } from './services/compliance.service.js'; // Resolve the absolute path to the public/ directory next to src/ const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -478,6 +479,7 @@ async function start() { await startEventListener(); startRefundSweep(); + startComplianceJob(); } catch (err) { app.log.error(err); process.exit(1); @@ -489,6 +491,7 @@ for (const sig of ['SIGTERM', 'SIGINT'] as const) { process.on(sig, () => { eventListener?.stop(); if (refundSweepInterval) clearInterval(refundSweepInterval); + stopComplianceJob(); process.exit(0); }); } diff --git a/micopay/backend/src/routes/admin.ts b/micopay/backend/src/routes/admin.ts index 3f51080..89b1b86 100644 --- a/micopay/backend/src/routes/admin.ts +++ b/micopay/backend/src/routes/admin.ts @@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyRequest } from "fastify"; import { config, type KycOperationType } from "../config.js"; import { pauseUser, unpauseUser } from "../services/abuse.service.js"; import { getKycAuditTrail, type GateDecision } from "../services/kyc-gate.service.js"; +import { generateMonthlyFiling } from "../services/compliance.service.js"; import { AuthError, NotFoundError } from "../utils/errors.js"; import db from "../db/schema.js"; @@ -95,4 +96,81 @@ export async function adminRoutes(app: FastifyInstance) { return { events }; }); + + /** + * GET /admin/compliance/alerts + * Query the compliance alerts. + */ + app.get("/admin/compliance/alerts", async (request) => { + const { user_id, reason, severity, limit } = (request.query as { + user_id?: string; + reason?: string; + severity?: string; + limit?: string; + } | undefined) ?? {}; + + const parsedLimit = limit ? parseInt(limit, 10) : 50; + const queryLimit = isNaN(parsedLimit) || parsedLimit <= 0 ? 50 : Math.min(parsedLimit, 500); + + const alerts = await db.getMany( + `SELECT id, user_id, reason, severity, details, created_at, sla_deadline + FROM compliance_alerts + ORDER BY created_at DESC + LIMIT ${queryLimit}` + ); + + const filtered = alerts.filter((row) => { + if (user_id && row.user_id !== user_id) return false; + if (reason && row.reason !== reason) return false; + if (severity && row.severity !== severity) return false; + return true; + }); + + return { alerts: filtered }; + }); + + /** + * GET /admin/compliance/filings + * Query the compliance filings. + */ + app.get("/admin/compliance/filings", async (request) => { + const { is_zero_report, limit } = (request.query as { + is_zero_report?: string; + limit?: string; + } | undefined) ?? {}; + + const parsedLimit = limit ? parseInt(limit, 10) : 50; + const queryLimit = isNaN(parsedLimit) || parsedLimit <= 0 ? 50 : Math.min(parsedLimit, 500); + + const filings = await db.getMany( + `SELECT id, period_start, period_end, filing_type, report_data, is_zero_report, created_at + FROM compliance_filings + ORDER BY period_start DESC + LIMIT ${queryLimit}` + ); + + const filtered = filings.filter((row) => { + if (is_zero_report !== undefined && String(row.is_zero_report) !== is_zero_report) return false; + return true; + }); + + return { filings: filtered }; + }); + + /** + * POST /admin/compliance/filings/trigger + * Manually trigger compliance filing generation for a specific month. + */ + app.post("/admin/compliance/filings/trigger", async (request) => { + const { year, month } = (request.body as { year?: number | string; month?: number | string } | undefined) ?? {}; + const numericYear = year !== undefined ? Number(year) : NaN; + const numericMonth = month !== undefined ? Number(month) : NaN; + + if (isNaN(numericYear) || isNaN(numericMonth) || numericMonth < 1 || numericMonth > 12) { + throw new Error("year and month (1-12) are required in body"); + } + + const filing = await generateMonthlyFiling(numericYear, numericMonth); + return { success: true, filing }; + }); } diff --git a/micopay/backend/src/services/compliance.service.ts b/micopay/backend/src/services/compliance.service.ts new file mode 100644 index 0000000..e382e9b --- /dev/null +++ b/micopay/backend/src/services/compliance.service.ts @@ -0,0 +1,250 @@ +import db from '../db/schema.js'; +import { config } from '../config.js'; +import pino from 'pino'; + +const logger = pino({ name: 'compliance.service' }); + +export interface AggregatedOperation { + operationId: string; + type: string; + amountMxn: number; + timestamp: string; +} + +export interface ReportableUserRecord { + userId: string; + stellarAddress: string; + username: string; + totalVolumeMxn: number; + operationsCount: number; + operations: AggregatedOperation[]; +} + +export interface SatReportData { + period: string; + generatedAt: string; + thresholdUma: number; + umaValueMxn: number; + thresholdMxn: number; + isZeroReport: boolean; + reportableUsersCount: number; + records: ReportableUserRecord[]; +} + +/** + * Aggregates operations for a given calendar month (1-indexed month: 1-12). + * Operates on JS side to ensure Postgres and in-memory fallback behaves identically. + */ +export async function aggregateMonthlyOperations(year: number, month: number): Promise { + const periodStart = new Date(Date.UTC(year, month - 1, 1, 0, 0, 0, 0)); + const periodEnd = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)); + + // 1. Fetch user map for username and stellar_address resolution + const users = await db.getMany<{ id: string; username: string | null; stellar_address: string }>( + `SELECT id, username, stellar_address FROM users` + ); + const userMap = new Map(users.map((u) => [u.id, u])); + + // 2. Fetch completed P2P trades in period + const trades = await db.getMany<{ id: string; buyer_id: string; amount_mxn: string | number; completed_at: string }>( + `SELECT id, buyer_id, amount_mxn, completed_at FROM trades WHERE status = 'completed'` + ); + + // 3. Fetch passed gate decisions (cash_in, cash_out, cetes_purchase) + const riskEvents = await db.getMany<{ id: string; actor_user_id: string | null; details: any; created_at: string }>( + `SELECT id, actor_user_id, details, created_at FROM platform_risk_events WHERE action = $1`, + ['kyc_gate.decision'] + ); + + const userOperations: Record = {}; + + // Process trades + for (const t of trades) { + if (!t.completed_at) continue; + const completedTime = new Date(t.completed_at).getTime(); + if (completedTime >= periodStart.getTime() && completedTime < periodEnd.getTime()) { + if (!userOperations[t.buyer_id]) { + userOperations[t.buyer_id] = []; + } + userOperations[t.buyer_id].push({ + operationId: t.id, + type: 'p2p_transfer', + amountMxn: Number(t.amount_mxn), + timestamp: t.completed_at, + }); + } + } + + // Process other gated operations (cash_in, cash_out, cetes_purchase) + for (const event of riskEvents) { + if (!event.actor_user_id) continue; + const details = event.details || {}; + if (details.gate_decision !== 'pass') continue; + const opType = details.operation_type; + if (opType === 'p2p_transfer') continue; // Handled by actual completed trades + + const createdTime = new Date(event.created_at).getTime(); + if (createdTime >= periodStart.getTime() && createdTime < periodEnd.getTime()) { + if (!userOperations[event.actor_user_id]) { + userOperations[event.actor_user_id] = []; + } + userOperations[event.actor_user_id].push({ + operationId: event.id, + type: opType || 'unknown', + amountMxn: Number(details.amount_mxn || 0), + timestamp: event.created_at, + }); + } + } + + const thresholdMxn = config.umaDailyMxn * config.kycAvisoThresholdUma; + const records: ReportableUserRecord[] = []; + + for (const [userId, ops] of Object.entries(userOperations)) { + const totalVolumeMxn = ops.reduce((sum, op) => sum + op.amountMxn, 0); + if (totalVolumeMxn >= thresholdMxn) { + const u = userMap.get(userId); + records.push({ + userId, + stellarAddress: u?.stellar_address || '', + username: u?.username || 'Usuario Micopay', + totalVolumeMxn, + operationsCount: ops.length, + operations: ops, + }); + } + } + + return records; +} + +/** + * Generates the monthly report (or zero-report) and records it to compliance_filings. + */ +export async function generateMonthlyFiling(year: number, month: number): Promise { + const periodStart = new Date(Date.UTC(year, month - 1, 1, 0, 0, 0, 0)); + const periodEnd = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)); + + const records = await aggregateMonthlyOperations(year, month); + const isZeroReport = records.length === 0; + + const reportData: SatReportData = { + period: `${year}-${String(month).padStart(2, '0')}`, + generatedAt: new Date().toISOString(), + thresholdUma: config.kycAvisoThresholdUma, + umaValueMxn: config.umaDailyMxn, + thresholdMxn: config.umaDailyMxn * config.kycAvisoThresholdUma, + isZeroReport, + reportableUsersCount: records.length, + records, + }; + + const [filing] = await db.getMany( + `INSERT INTO compliance_filings (period_start, period_end, filing_type, report_data, is_zero_report) + VALUES ($1, $2, 'monthly_sat', $3, $4) + RETURNING *`, + [periodStart.toISOString(), periodEnd.toISOString(), reportData, isZeroReport] + ); + + logger.info( + { period: reportData.period, isZeroReport, recordsCount: records.length }, + '[compliance] Generated monthly SAT report' + ); + + return filing; +} + +/** + * Enters a compliance alert into the database. Exposes 24h SLA. + */ +export async function createComplianceAlert(input: { + userId: string; + reason: string; + severity?: string; + details?: Record; +}): Promise { + const { userId, reason, severity = 'medium', details = {} } = input; + const createdAt = new Date(); + const slaDeadline = new Date(createdAt.getTime() + 24 * 60 * 60 * 1000); + + const [alert] = await db.getMany( + `INSERT INTO compliance_alerts (user_id, reason, severity, details, created_at, sla_deadline) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [userId, reason, severity, details, createdAt.toISOString(), slaDeadline.toISOString()] + ); + + logger.warn( + { userId, reason, severity, deadline: slaDeadline.toISOString() }, + '[compliance] Compliance alert created' + ); + + return alert; +} + +/** + * Checks if the report for the previous month is missing and runs it if Day 17 is reached. + */ +export async function checkAndRunComplianceJob(): Promise { + try { + const now = new Date(); + const day = now.getUTCDate(); + + // Only run on or after Day 17 of the month + if (day < 17) { + return; + } + + let prevYear = now.getUTCFullYear(); + let prevMonth = now.getUTCMonth(); // 0-indexed, so 0 is Jan (we want Dec of prev year) + + if (prevMonth === 0) { + prevMonth = 12; + prevYear -= 1; + } + + const periodStart = new Date(Date.UTC(prevYear, prevMonth - 1, 1, 0, 0, 0, 0)); + + const existing = await db.getOne( + `SELECT id FROM compliance_filings WHERE period_start = $1 AND filing_type = 'monthly_sat'`, + [periodStart.toISOString()] + ); + + if (!existing) { + logger.info( + { prevYear, prevMonth }, + '[compliance] Scheduled check: Monthly report missing. Triggering generation...' + ); + await generateMonthlyFiling(prevYear, prevMonth); + } + } catch (err: any) { + logger.error({ err: err.message }, '[compliance] Scheduled check cycle failed'); + } +} + +let complianceJobInterval: NodeJS.Timeout | null = null; +const COMPLIANCE_CHECK_INTERVAL_MS = 60 * 60 * 1000; // Hourly check + +export function startComplianceJob(): void { + // Check immediately on start + checkAndRunComplianceJob().catch((err) => { + logger.error({ err: err.message }, '[compliance] Startup check failed'); + }); + + // Schedule hourly check + complianceJobInterval = setInterval(() => { + checkAndRunComplianceJob().catch((err) => { + logger.error({ err: err.message }, '[compliance] Scheduled check failed'); + }); + }, COMPLIANCE_CHECK_INTERVAL_MS); + + logger.info({ intervalMs: COMPLIANCE_CHECK_INTERVAL_MS }, '[compliance] Scheduler active'); +} + +export function stopComplianceJob(): void { + if (complianceJobInterval) { + clearInterval(complianceJobInterval); + complianceJobInterval = null; + logger.info('[compliance] Scheduler stopped'); + } +} diff --git a/micopay/backend/src/tests/compliance.test.ts b/micopay/backend/src/tests/compliance.test.ts new file mode 100644 index 0000000..5626ca1 --- /dev/null +++ b/micopay/backend/src/tests/compliance.test.ts @@ -0,0 +1,260 @@ +import { strictEqual, ok, throws, rejects } from "assert"; +import { randomUUID } from "crypto"; +import db from "../db/schema.js"; +import { config } from "../config.js"; +import { + aggregateMonthlyOperations, + generateMonthlyFiling, + createComplianceAlert, + checkAndRunComplianceJob, +} from "../services/compliance.service.js"; +import { logAuditEvent } from "../services/audit.service.js"; + +async function seedUser(username: string): Promise { + const stellarAddress = `G${randomUUID().replace(/-/g, "").toUpperCase().slice(0, 55)}`; + const user = await db.getOne<{ id: string }>( + `INSERT INTO users (stellar_address, username, kyc_level, kyc_level_verified_at) + VALUES ($1, $2, 0, NULL) + RETURNING id`, + [stellarAddress, username], + ); + if (!user?.id) throw new Error("Failed to seed user"); + return user.id; +} + +// ── Test 1: Aggregation threshold edge cases ─────────────────────────────── +async function testAggregationThresholds() { + const buyerId1 = await seedUser(`buyer_${randomUUID().slice(0, 8)}`); + const buyerId2 = await seedUser(`buyer_${randomUUID().slice(0, 8)}`); + const buyerId3 = await seedUser(`buyer_${randomUUID().slice(0, 8)}`); + const sellerId = await seedUser(`seller_${randomUUID().slice(0, 8)}`); + + const year = 2026; + const month = 6; // June + const completedAt = "2026-06-15T12:00:00.000Z"; + + // Threshold MXN = 210 * 117.31 = 24635.1 + const thresholdMxn = config.umaDailyMxn * config.kycAvisoThresholdUma; + const underAmount = Math.floor(thresholdMxn - 10); + const exactAmount = thresholdMxn; + const overAmount = Math.ceil(thresholdMxn + 10); + + // User 1: Under threshold (P2P Trade) + await db.execute( + `INSERT INTO trades (id, seller_id, buyer_id, amount_mxn, amount_stroops, secret_hash, status, completed_at, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, 'completed', $7, $8, $9)`, + [ + randomUUID(), + sellerId, + buyerId1, + underAmount, + (underAmount * 10000000).toString(), + `hash_${randomUUID()}`, + completedAt, + completedAt, + completedAt, + ], + ); + + // User 2: Exactly at threshold (P2P Trade) + await db.execute( + `INSERT INTO trades (id, seller_id, buyer_id, amount_mxn, amount_stroops, secret_hash, status, completed_at, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, 'completed', $7, $8, $9)`, + [ + randomUUID(), + sellerId, + buyerId2, + exactAmount, + (exactAmount * 10000000).toString(), + `hash_${randomUUID()}`, + completedAt, + completedAt, + completedAt, + ], + ); + + // User 3: Over threshold (Cash In Risk Event) + await logAuditEvent({ + action: "kyc_gate.decision", + actorUserId: buyerId3, + entityType: "kyc_gate", + entityId: "cash_in", + details: { + operation_type: "cash_in", + amount_mxn: overAmount, + gate_decision: "pass", + }, + }); + // Update created_at to target month manually because DEFAULT now() is used + await db.execute( + `UPDATE platform_risk_events SET created_at = $2 WHERE actor_user_id = $1`, + [buyerId3, completedAt], + ).catch(() => { + // In memory fallback, platform_risk_events doesn't allow UPDATE, so we'll simulate by + // setting it in our mock logic or in memory table directly if needed. + // Wait, platform_risk_events doesn't allow UPDATE due to append-only trigger! + // That means we must seed it with the correct created_at from the beginning. + // But logAuditEvent doesn't let us pass created_at! + // So for test, let's insert it directly into db.execute! + }); + + // Let's insert the risk event directly with created_at to bypass logAuditEvent limits + await db.execute( + `INSERT INTO platform_risk_events (action, actor_user_id, entity_type, entity_id, details, created_at) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + "kyc_gate.decision", + buyerId3, + "kyc_gate", + "cash_in", + { + operation_type: "cash_in", + amount_mxn: overAmount, + gate_decision: "pass", + }, + completedAt, + ], + ); + + const reportableRecords = await aggregateMonthlyOperations(year, month); + + // User 1 should be excluded. User 2 and 3 should be included. + const user1Record = reportableRecords.find((r) => r.userId === buyerId1); + const user2Record = reportableRecords.find((r) => r.userId === buyerId2); + const user3Record = reportableRecords.find((r) => r.userId === buyerId3); + + strictEqual(user1Record, undefined, "User under threshold should not be reportable"); + ok(user2Record !== undefined, "User exactly at threshold should be reportable"); + ok(user3Record !== undefined, "User over threshold should be reportable"); + strictEqual(user2Record.totalVolumeMxn, exactAmount, "Volume calculation should be exact"); + strictEqual(user3Record.totalVolumeMxn, overAmount, "Volume calculation should match event details"); + + console.log("testAggregationThresholds: OK"); +} + +// ── Test 2: Zero-report generation logic ─────────────────────────────────── +async function testZeroReport() { + const year = 2026; + const month = 5; // May (no operations seeded) + + const filing = await generateMonthlyFiling(year, month); + ok(filing !== null, "Should generate filing record"); + strictEqual(filing.is_zero_report, true, "is_zero_report should be true"); + strictEqual(filing.report_data.isZeroReport, true, "report_data.isZeroReport should be true"); + strictEqual(filing.report_data.records.length, 0, "No records in zero-report"); + + console.log("testZeroReport: OK"); +} + +// ── Test 3: Compliance alerts SLA deadlines ─────────────────────────────── +async function testComplianceAlerts() { + const userId = await seedUser(`alert_${randomUUID().slice(0, 8)}`); + const alert = await createComplianceAlert({ + userId, + reason: "velocity_limit_exceeded", + severity: "high", + details: { txsCount: 15 }, + }); + + ok(alert.id !== undefined, "Alert should have an ID"); + strictEqual(alert.reason, "velocity_limit_exceeded", "Reason should match"); + strictEqual(alert.severity, "high", "Severity should match"); + + const createdTime = new Date(alert.created_at).getTime(); + const deadlineTime = new Date(alert.sla_deadline).getTime(); + const diffHours = (deadlineTime - createdTime) / (60 * 60 * 1000); + + // Diff should be exactly 24 hours + strictEqual(Math.round(diffHours), 24, "SLA deadline should be exactly 24 hours after creation"); + + console.log("testComplianceAlerts: OK"); +} + +// ── Test 4: Append-only enforcement ──────────────────────────────────────── +async function testAppendOnlyEnforcement() { + const userId = await seedUser(`append_${randomUUID().slice(0, 8)}`); + + // Insert an alert + const alert = await createComplianceAlert({ + userId, + reason: "test_append_only", + severity: "low", + }); + + // Try updating the alert (should reject or throw) + await rejects( + db.execute(`UPDATE compliance_alerts SET reason = 'hacked' WHERE id = $1`, [alert.id]), + /Updates and deletions are not allowed on this table/i, + "Alert update should be blocked" + ); + + // Try deleting the alert (should reject or throw) + await rejects( + db.execute(`DELETE FROM compliance_alerts WHERE id = $1`, [alert.id]), + /Updates and deletions are not allowed on this table/i, + "Alert deletion should be blocked" + ); + + console.log("testAppendOnlyEnforcement: OK"); +} + +// ── Test 5: Scheduled job integration end-to-end ─────────────────────────── +async function testScheduledJobIntegration() { + // Clear any existing filings for test period first + await db.execute(`DELETE FROM compliance_filings`).catch(() => { + // If append-only triggers block DELETE, that's fine. We'll verify missing report generation using a unique month. + }); + + const now = new Date(); + // We'll test with a unique month so that checkAndRunComplianceJob will definitely find it missing + // Let's manually trigger checkAndRunComplianceJob. + // Wait, checkAndRunComplianceJob depends on current system date's getUTCDate() >= 17. + // Let's temporarily override Date.prototype.getUTCDate (or Mock Date) in test to make it think it's Day 17. + const originalGetUTCDate = Date.prototype.getUTCDate; + const originalGetUTCMonth = Date.prototype.getUTCMonth; + const originalGetUTCFullYear = Date.prototype.getUTCFullYear; + + // Let's mock Date.prototype to return: Day 17, Month 10 (November), Year 2026. + // This means previous month is October 2026. + Date.prototype.getUTCDate = () => 17; + Date.prototype.getUTCMonth = () => 10; // 0-indexed, so 10 is November + Date.prototype.getUTCFullYear = () => 2026; + + try { + const periodStart = new Date(Date.UTC(2026, 9, 1, 0, 0, 0, 0)); // October 1st + + // Run the scheduler check + await checkAndRunComplianceJob(); + + // Verify report was generated for October 2026 + const report = await db.getOne( + `SELECT id, is_zero_report FROM compliance_filings WHERE period_start = $1 AND filing_type = 'monthly_sat'`, + [periodStart.toISOString()] + ); + + ok(report !== null, "Filing should be generated by checkAndRunComplianceJob"); + strictEqual(report.is_zero_report, true, "Should be zero report because no data was seeded for October 2026"); + } finally { + // Restore original Date functions + Date.prototype.getUTCDate = originalGetUTCDate; + Date.prototype.getUTCMonth = originalGetUTCMonth; + Date.prototype.getUTCFullYear = originalGetUTCFullYear; + } + + console.log("testScheduledJobIntegration: OK"); +} + +async function run() { + console.log("Running compliance service tests..."); + await testAggregationThresholds(); + await testZeroReport(); + await testComplianceAlerts(); + await testAppendOnlyEnforcement(); + await testScheduledJobIntegration(); + console.log("All compliance service tests passed."); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/micopay/sql/migrations/20260721130000_compliance_reporting.down.sql b/micopay/sql/migrations/20260721130000_compliance_reporting.down.sql new file mode 100644 index 0000000..5b5b2a1 --- /dev/null +++ b/micopay/sql/migrations/20260721130000_compliance_reporting.down.sql @@ -0,0 +1,10 @@ +-- Down migration for Compliance Reporting Engine (SAT/UIF) + +DROP TRIGGER IF EXISTS enforce_append_only_compliance_filings ON compliance_filings; +DROP TRIGGER IF EXISTS enforce_append_only_compliance_alerts ON compliance_alerts; +DROP TRIGGER IF EXISTS enforce_append_only_risk_events ON platform_risk_events; + +DROP FUNCTION IF EXISTS prevent_update_or_delete(); + +DROP TABLE IF EXISTS compliance_filings; +DROP TABLE IF EXISTS compliance_alerts; diff --git a/micopay/sql/migrations/20260721130000_compliance_reporting.up.sql b/micopay/sql/migrations/20260721130000_compliance_reporting.up.sql new file mode 100644 index 0000000..34134a3 --- /dev/null +++ b/micopay/sql/migrations/20260721130000_compliance_reporting.up.sql @@ -0,0 +1,51 @@ +-- Up migration for Compliance Reporting Engine (SAT/UIF) + +-- 1. Create compliance_alerts table +CREATE TABLE IF NOT EXISTS compliance_alerts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + severity VARCHAR(16) NOT NULL DEFAULT 'medium', + details JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sla_deadline TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours') +); + +CREATE INDEX IF NOT EXISTS idx_compliance_alerts_user ON compliance_alerts (user_id); +CREATE INDEX IF NOT EXISTS idx_compliance_alerts_created ON compliance_alerts (created_at DESC); + +-- 2. Create compliance_filings table +CREATE TABLE IF NOT EXISTS compliance_filings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + filing_type VARCHAR(32) NOT NULL DEFAULT 'monthly_sat', + report_data JSONB NOT NULL, + is_zero_report BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_compliance_filings_period ON compliance_filings (period_start DESC, period_end DESC); + +-- 3. Append-only triggers to prevent UPDATE and DELETE on audit log, alerts, and filings +CREATE OR REPLACE FUNCTION prevent_update_or_delete() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'Updates and deletions are not allowed on this table (append-only compliance data).'; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS enforce_append_only_risk_events ON platform_risk_events; +CREATE TRIGGER enforce_append_only_risk_events + BEFORE UPDATE OR DELETE ON platform_risk_events + FOR EACH ROW EXECUTE FUNCTION prevent_update_or_delete(); + +DROP TRIGGER IF EXISTS enforce_append_only_compliance_alerts ON compliance_alerts; +CREATE TRIGGER enforce_append_only_compliance_alerts + BEFORE UPDATE OR DELETE ON compliance_alerts + FOR EACH ROW EXECUTE FUNCTION prevent_update_or_delete(); + +DROP TRIGGER IF EXISTS enforce_append_only_compliance_filings ON compliance_filings; +CREATE TRIGGER enforce_append_only_compliance_filings + BEFORE UPDATE OR DELETE ON compliance_filings + FOR EACH ROW EXECUTE FUNCTION prevent_update_or_delete(); diff --git a/micopay/sql/migrations/RETENTION_POLICY.md b/micopay/sql/migrations/RETENTION_POLICY.md new file mode 100644 index 0000000..8e4d018 --- /dev/null +++ b/micopay/sql/migrations/RETENTION_POLICY.md @@ -0,0 +1,27 @@ +# Compliance Audit and Reporting Retention Policy + +This document defines the retention policy for compliance records generated by the Micopay protocol. + +## 1. Regulated Tables + +The following tables are subject to this policy: +- `platform_risk_events` (audit log of gate decisions) +- `compliance_alerts` (investigation alerts) +- `compliance_filings` (SAT reports and zero-reports) + +## 2. Retention Period + +> [!IMPORTANT] +> All records in the tables listed above **MUST be retained for a minimum of 10 years** from their creation date, in compliance with LFPIORPI and local fintech/tax reporting regulations. + +## 3. Database Layer Enforcement + +To prevent accidental or malicious modification/deletion of data: +1. **Append-Only Triggers:** Database triggers block any `UPDATE` or `DELETE` operations on these tables. +2. **In-Memory Guardrails:** The testing database fallback mocks this behavior by raising errors when modifications are attempted on these tables. + +Do **NOT** implement any cron, worker, or cleanup job that attempts to prune, archive, or truncate these tables before the 10-year period has elapsed. + +## 4. Encryption at Rest + +In production environments, all databases containing these tables (and all database backups) **MUST be encrypted at rest** using industry-standard AES-256 encryption. This protects sensitive user transaction data and compliance filings from unauthorized physical access or backup theft.