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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions micopay/backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions micopay/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:kyc-didit": "node --import tsx src/tests/kyc-didit.test.ts",
"test:security": "node --import tsx src/tests/security.test.ts"
},
Expand Down
5 changes: 2 additions & 3 deletions micopay/backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,8 @@ export const config = {
kycOperationThresholds: parseKycOperationThresholds(process.env.KYC_OPERATION_THRESHOLDS_JSON),

// LFPIORPI aviso (reporting) thresholds, in UMA — consumed by the compliance
// reporting engine (#317, not yet built), NOT by the KYC gate above. Kept here
// so the verified 2026-07-21 values aren't lost. UMA 2026 = $117.31 MXN/day.
// reporting engine (#317), NOT by the KYC gate above. 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
Expand Down Expand Up @@ -297,4 +297,3 @@ export function getCorsOptions() {
maxAge: 86400, // 24 hours
};
}

10 changes: 10 additions & 0 deletions micopay/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const mem: Record<string, any[]> = {
platform_risk_events: [],
trade_messages: [],
trade_disputes: [],
compliance_alerts: [],
compliance_filings: [],
};

function memNow() {
Expand Down Expand Up @@ -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];

Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions micopay/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -478,6 +479,7 @@ async function start() {

await startEventListener();
startRefundSweep();
startComplianceJob();
} catch (err) {
app.log.error(err);
process.exit(1);
Expand All @@ -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);
});
}
Expand Down
78 changes: 78 additions & 0 deletions micopay/backend/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 };
});
}
Loading