Compare commits
17 Commits
22543589c3
...
d22e898909
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d22e898909 | ||
|
|
3ab6d8d3e9 | ||
|
|
c351b5aeda | ||
|
|
b977f92141 | ||
|
|
69d7590834 | ||
|
|
6fc81b1c0d | ||
|
|
bcabbd4959 | ||
|
|
12dda005af | ||
|
|
d8f9f92389 | ||
|
|
96e1ea554c | ||
|
|
b064f15404 | ||
|
|
7eaeefa09d | ||
|
|
2b5a856845 | ||
|
|
8bfb8912c1 | ||
|
|
d6b86dbbd3 | ||
|
|
f96a9c55c5 | ||
|
|
0d17fe3494 |
@@ -28,8 +28,11 @@
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mercadopago": "^2.12.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.18.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -41,6 +44,8 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/pg": "^8.18.0",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.3.0"
|
||||
|
||||
@@ -12,9 +12,9 @@ model Tenant {
|
||||
nombre String
|
||||
rfc String @unique
|
||||
plan Plan @default(starter)
|
||||
schemaName String @unique @map("schema_name")
|
||||
cfdiLimit Int @map("cfdi_limit")
|
||||
usersLimit Int @map("users_limit")
|
||||
databaseName String @unique @map("database_name")
|
||||
cfdiLimit Int @default(100) @map("cfdi_limit")
|
||||
usersLimit Int @default(1) @map("users_limit")
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
@@ -22,6 +22,8 @@ model Tenant {
|
||||
users User[]
|
||||
fielCredential FielCredential?
|
||||
satSyncJobs SatSyncJob[]
|
||||
subscriptions Subscription[]
|
||||
payments Payment[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
@@ -76,8 +78,12 @@ model FielCredential {
|
||||
cerData Bytes @map("cer_data")
|
||||
keyData Bytes @map("key_data")
|
||||
keyPasswordEncrypted Bytes @map("key_password_encrypted")
|
||||
encryptionIv Bytes @map("encryption_iv")
|
||||
encryptionTag Bytes @map("encryption_tag")
|
||||
cerIv Bytes @map("cer_iv")
|
||||
cerTag Bytes @map("cer_tag")
|
||||
keyIv Bytes @map("key_iv")
|
||||
keyTag Bytes @map("key_tag")
|
||||
passwordIv Bytes @map("password_iv")
|
||||
passwordTag Bytes @map("password_tag")
|
||||
serialNumber String? @map("serial_number") @db.VarChar(50)
|
||||
validFrom DateTime @map("valid_from")
|
||||
validUntil DateTime @map("valid_until")
|
||||
@@ -90,6 +96,46 @@ model FielCredential {
|
||||
@@map("fiel_credentials")
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
plan Plan
|
||||
mpPreapprovalId String? @map("mp_preapproval_id")
|
||||
status String @default("pending")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
frequency String @default("monthly")
|
||||
currentPeriodStart DateTime? @map("current_period_start")
|
||||
currentPeriodEnd DateTime? @map("current_period_end")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
payments Payment[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([status])
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
subscriptionId String? @map("subscription_id")
|
||||
mpPaymentId String? @map("mp_payment_id")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
status String @default("pending")
|
||||
paymentMethod String? @map("payment_method")
|
||||
paidAt DateTime? @map("paid_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([subscriptionId])
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
model SatSyncJob {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
|
||||
82
apps/api/scripts/decrypt-fiel.ts
Normal file
82
apps/api/scripts/decrypt-fiel.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CLI script to decrypt FIEL credentials from filesystem backup.
|
||||
* Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>
|
||||
*
|
||||
* Decrypted files are written to /tmp/horux-fiel-<RFC>/ and auto-deleted after 30 minutes.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { createDecipheriv, createHash } from 'crypto';
|
||||
|
||||
const FIEL_PATH = process.env.FIEL_STORAGE_PATH || '/var/horux/fiel';
|
||||
const FIEL_KEY = process.env.FIEL_ENCRYPTION_KEY;
|
||||
|
||||
const rfc = process.argv[2];
|
||||
if (!rfc) {
|
||||
console.error('Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!FIEL_KEY) {
|
||||
console.error('Error: FIEL_ENCRYPTION_KEY environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function deriveKey(): Buffer {
|
||||
return createHash('sha256').update(FIEL_KEY!).digest();
|
||||
}
|
||||
|
||||
function decryptBuffer(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
|
||||
const key = deriveKey();
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fielDir = join(FIEL_PATH, rfc.toUpperCase());
|
||||
const outputDir = `/tmp/horux-fiel-${rfc.toUpperCase()}`;
|
||||
|
||||
console.log(`Reading encrypted FIEL from: ${fielDir}`);
|
||||
|
||||
// Read encrypted certificate
|
||||
const cerEnc = await readFile(join(fielDir, 'certificate.cer.enc'));
|
||||
const cerIv = await readFile(join(fielDir, 'certificate.cer.iv'));
|
||||
const cerTag = await readFile(join(fielDir, 'certificate.cer.tag'));
|
||||
|
||||
// Read encrypted private key
|
||||
const keyEnc = await readFile(join(fielDir, 'private_key.key.enc'));
|
||||
const keyIv = await readFile(join(fielDir, 'private_key.key.iv'));
|
||||
const keyTag = await readFile(join(fielDir, 'private_key.key.tag'));
|
||||
|
||||
// Read and decrypt metadata
|
||||
const metaEnc = await readFile(join(fielDir, 'metadata.json.enc'));
|
||||
const metaIv = await readFile(join(fielDir, 'metadata.json.iv'));
|
||||
const metaTag = await readFile(join(fielDir, 'metadata.json.tag'));
|
||||
|
||||
// Decrypt all
|
||||
const cerData = decryptBuffer(cerEnc, cerIv, cerTag);
|
||||
const keyData = decryptBuffer(keyEnc, keyIv, keyTag);
|
||||
const metadata = JSON.parse(decryptBuffer(metaEnc, metaIv, metaTag).toString('utf-8'));
|
||||
|
||||
// Write decrypted files
|
||||
await mkdir(outputDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(join(outputDir, 'certificate.cer'), cerData, { mode: 0o600 });
|
||||
await writeFile(join(outputDir, 'private_key.key'), keyData, { mode: 0o600 });
|
||||
await writeFile(join(outputDir, 'metadata.json'), JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
||||
|
||||
console.log(`\nDecrypted files written to: ${outputDir}`);
|
||||
console.log('Metadata:', metadata);
|
||||
console.log('\nFiles will be auto-deleted in 30 minutes.');
|
||||
|
||||
// Auto-delete after 30 minutes
|
||||
setTimeout(async () => {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
console.log(`Cleaned up ${outputDir}`);
|
||||
process.exit(0);
|
||||
}, 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to decrypt FIEL:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import { usuariosRoutes } from './routes/usuarios.routes.js';
|
||||
import { tenantsRoutes } from './routes/tenants.routes.js';
|
||||
import fielRoutes from './routes/fiel.routes.js';
|
||||
import satRoutes from './routes/sat.routes.js';
|
||||
import { webhookRoutes } from './routes/webhook.routes.js';
|
||||
import { subscriptionRoutes } from './routes/subscription.routes.js';
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
@@ -47,6 +49,8 @@ app.use('/api/usuarios', usuariosRoutes);
|
||||
app.use('/api/tenants', tenantsRoutes);
|
||||
app.use('/api/fiel', fielRoutes);
|
||||
app.use('/api/sat', satRoutes);
|
||||
app.use('/api/webhooks', webhookRoutes);
|
||||
app.use('/api/subscriptions', subscriptionRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorMiddleware);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Pool, type PoolConfig } from 'pg';
|
||||
import { env } from './env.js';
|
||||
|
||||
// ===========================================
|
||||
// Prisma Client (central database: horux360)
|
||||
// ===========================================
|
||||
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined;
|
||||
@@ -11,3 +17,303 @@ export const prisma = globalThis.prisma || new PrismaClient({
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.prisma = prisma;
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// TenantConnectionManager (per-tenant DBs)
|
||||
// ===========================================
|
||||
|
||||
interface PoolEntry {
|
||||
pool: Pool;
|
||||
lastAccess: Date;
|
||||
}
|
||||
|
||||
function parseDatabaseUrl(url: string) {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parseInt(parsed.port || '5432'),
|
||||
user: decodeURIComponent(parsed.username),
|
||||
password: decodeURIComponent(parsed.password),
|
||||
};
|
||||
}
|
||||
|
||||
class TenantConnectionManager {
|
||||
private pools: Map<string, PoolEntry> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private dbConfig: { host: string; port: number; user: string; password: string };
|
||||
|
||||
constructor() {
|
||||
this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
|
||||
this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a connection pool for a tenant's database.
|
||||
*/
|
||||
getPool(tenantId: string, databaseName: string): Pool {
|
||||
const entry = this.pools.get(tenantId);
|
||||
if (entry) {
|
||||
entry.lastAccess = new Date();
|
||||
return entry.pool;
|
||||
}
|
||||
|
||||
const poolConfig: PoolConfig = {
|
||||
host: this.dbConfig.host,
|
||||
port: this.dbConfig.port,
|
||||
user: this.dbConfig.user,
|
||||
password: this.dbConfig.password,
|
||||
database: databaseName,
|
||||
max: 3,
|
||||
idleTimeoutMillis: 300_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
};
|
||||
|
||||
const pool = new Pool(poolConfig);
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
|
||||
});
|
||||
|
||||
this.pools.set(tenantId, { pool, lastAccess: new Date() });
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new database for a tenant with all required tables and indexes.
|
||||
*/
|
||||
async provisionDatabase(rfc: string): Promise<string> {
|
||||
const databaseName = `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
const adminPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: 'postgres',
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
const exists = await adminPool.query(
|
||||
`SELECT 1 FROM pg_database WHERE datname = $1`,
|
||||
[databaseName]
|
||||
);
|
||||
|
||||
if (exists.rows.length > 0) {
|
||||
throw new Error(`Database ${databaseName} already exists`);
|
||||
}
|
||||
|
||||
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
|
||||
|
||||
const tenantPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: databaseName,
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.createTables(tenantPool);
|
||||
await this.createIndexes(tenantPool);
|
||||
} finally {
|
||||
await tenantPool.end();
|
||||
}
|
||||
|
||||
return databaseName;
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete: rename database so it can be recovered.
|
||||
*/
|
||||
async deprovisionDatabase(databaseName: string): Promise<void> {
|
||||
// Close any active pool for this tenant
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
// We check pool config to match the database
|
||||
if ((entry.pool as any).options?.database === databaseName) {
|
||||
await entry.pool.end().catch(() => {});
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const adminPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: 'postgres',
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await adminPool.query(`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = $1 AND pid <> pg_backend_pid()
|
||||
`, [databaseName]);
|
||||
|
||||
await adminPool.query(
|
||||
`ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
|
||||
);
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate (close and remove) a specific tenant's pool.
|
||||
*/
|
||||
invalidatePool(tenantId: string): void {
|
||||
const entry = this.pools.get(tenantId);
|
||||
if (entry) {
|
||||
entry.pool.end().catch(() => {});
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove idle pools (not accessed in last 5 minutes).
|
||||
*/
|
||||
private cleanupIdlePools(): void {
|
||||
const now = Date.now();
|
||||
const maxIdle = 5 * 60 * 1000;
|
||||
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
if (now - entry.lastAccess.getTime() > maxIdle) {
|
||||
entry.pool.end().catch((err) =>
|
||||
console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message)
|
||||
);
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown: close all pools.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
const closePromises = Array.from(this.pools.values()).map((entry) =>
|
||||
entry.pool.end()
|
||||
);
|
||||
await Promise.all(closePromises);
|
||||
this.pools.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats about active pools.
|
||||
*/
|
||||
getStats(): { activePools: number; tenantIds: string[] } {
|
||||
return {
|
||||
activePools: this.pools.size,
|
||||
tenantIds: Array.from(this.pools.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
private async createTables(pool: Pool): Promise<void> {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS cfdis (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
|
||||
tipo VARCHAR(20) NOT NULL DEFAULT 'ingreso',
|
||||
serie VARCHAR(25),
|
||||
folio VARCHAR(40),
|
||||
fecha_emision TIMESTAMP NOT NULL,
|
||||
fecha_timbrado TIMESTAMP,
|
||||
rfc_emisor VARCHAR(13) NOT NULL,
|
||||
nombre_emisor VARCHAR(300) NOT NULL,
|
||||
rfc_receptor VARCHAR(13) NOT NULL,
|
||||
nombre_receptor VARCHAR(300) NOT NULL,
|
||||
subtotal DECIMAL(18,2) DEFAULT 0,
|
||||
descuento DECIMAL(18,2) DEFAULT 0,
|
||||
iva DECIMAL(18,2) DEFAULT 0,
|
||||
isr_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
total DECIMAL(18,2) DEFAULT 0,
|
||||
moneda VARCHAR(10) DEFAULT 'MXN',
|
||||
tipo_cambio DECIMAL(10,4) DEFAULT 1,
|
||||
metodo_pago VARCHAR(10),
|
||||
forma_pago VARCHAR(10),
|
||||
uso_cfdi VARCHAR(10),
|
||||
estado VARCHAR(20) DEFAULT 'vigente',
|
||||
xml_url TEXT,
|
||||
pdf_url TEXT,
|
||||
xml_original TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
last_sat_sync TIMESTAMP,
|
||||
sat_sync_job_id UUID,
|
||||
source VARCHAR(20) DEFAULT 'manual'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iva_mensual (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
año INTEGER NOT NULL,
|
||||
mes INTEGER NOT NULL,
|
||||
iva_trasladado DECIMAL(18,2) DEFAULT 0,
|
||||
iva_acreditable DECIMAL(18,2) DEFAULT 0,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
resultado DECIMAL(18,2) DEFAULT 0,
|
||||
acumulado DECIMAL(18,2) DEFAULT 0,
|
||||
estado VARCHAR(20) DEFAULT 'pendiente',
|
||||
fecha_declaracion TIMESTAMP,
|
||||
UNIQUE(año, mes)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS isr_mensual (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
año INTEGER NOT NULL,
|
||||
mes INTEGER NOT NULL,
|
||||
ingresos_acumulados DECIMAL(18,2) DEFAULT 0,
|
||||
deducciones DECIMAL(18,2) DEFAULT 0,
|
||||
base_gravable DECIMAL(18,2) DEFAULT 0,
|
||||
isr_causado DECIMAL(18,2) DEFAULT 0,
|
||||
isr_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
isr_a_pagar DECIMAL(18,2) DEFAULT 0,
|
||||
estado VARCHAR(20) DEFAULT 'pendiente',
|
||||
fecha_declaracion TIMESTAMP,
|
||||
UNIQUE(año, mes)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alertas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
mensaje TEXT,
|
||||
prioridad VARCHAR(20) DEFAULT 'media',
|
||||
fecha_vencimiento TIMESTAMP,
|
||||
leida BOOLEAN DEFAULT FALSE,
|
||||
resuelta BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendario_fiscal (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
descripcion TEXT,
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
fecha_limite TIMESTAMP NOT NULL,
|
||||
recurrencia VARCHAR(20) DEFAULT 'unica',
|
||||
completado BOOLEAN DEFAULT FALSE,
|
||||
notas TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
private async createIndexes(pool: Pool): Promise<void> {
|
||||
await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo ON cfdis(tipo);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor ON cfdis(rfc_emisor);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor ON cfdis(rfc_receptor);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_estado ON cfdis(estado);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_emisor_trgm ON cfdis USING gin(nombre_emisor gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_receptor_trgm ON cfdis USING gin(nombre_receptor gin_trgm_ops);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const tenantDb = new TenantConnectionManager();
|
||||
|
||||
@@ -13,6 +13,28 @@ const envSchema = z.object({
|
||||
JWT_EXPIRES_IN: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||
|
||||
// Frontend URL (for MercadoPago back_url, emails, etc.)
|
||||
FRONTEND_URL: z.string().default('https://horux360.consultoria-as.com'),
|
||||
|
||||
// FIEL encryption (separate from JWT to allow independent rotation)
|
||||
FIEL_ENCRYPTION_KEY: z.string().min(32).default('dev-fiel-encryption-key-min-32-chars!!'),
|
||||
FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
|
||||
|
||||
// MercadoPago
|
||||
MP_ACCESS_TOKEN: z.string().optional(),
|
||||
MP_WEBHOOK_SECRET: z.string().optional(),
|
||||
MP_NOTIFICATION_URL: z.string().optional(),
|
||||
|
||||
// SMTP (Gmail Workspace)
|
||||
SMTP_HOST: z.string().default('smtp.gmail.com'),
|
||||
SMTP_PORT: z.string().default('587'),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().default('Horux360 <noreply@horuxfin.com>'),
|
||||
|
||||
// Admin notification email
|
||||
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as alertasService from '../services/alertas.service.js';
|
||||
|
||||
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { leida, resuelta, prioridad } = req.query;
|
||||
const alertas = await alertasService.getAlertas(req.tenantSchema!, {
|
||||
const alertas = await alertasService.getAlertas(req.tenantPool!, {
|
||||
leida: leida === 'true' ? true : leida === 'false' ? false : undefined,
|
||||
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
|
||||
prioridad: prioridad as string,
|
||||
@@ -17,7 +17,7 @@ export async function getAlertas(req: Request, res: Response, next: NextFunction
|
||||
|
||||
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(String(req.params.id)));
|
||||
const alerta = await alertasService.getAlertaById(req.tenantPool!, parseInt(String(req.params.id)));
|
||||
if (!alerta) {
|
||||
return res.status(404).json({ message: 'Alerta no encontrada' });
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export async function getAlerta(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
export async function createAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const alerta = await alertasService.createAlerta(req.tenantSchema!, req.body);
|
||||
const alerta = await alertasService.createAlerta(req.tenantPool!, req.body);
|
||||
res.status(201).json(alerta);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -38,7 +38,7 @@ export async function createAlerta(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(String(req.params.id)), req.body);
|
||||
const alerta = await alertasService.updateAlerta(req.tenantPool!, parseInt(String(req.params.id)), req.body);
|
||||
res.json(alerta);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -47,7 +47,7 @@ export async function updateAlerta(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(String(req.params.id)));
|
||||
await alertasService.deleteAlerta(req.tenantPool!, parseInt(String(req.params.id)));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -56,7 +56,7 @@ export async function deleteAlerta(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function getStats(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const stats = await alertasService.getStats(req.tenantSchema!);
|
||||
const stats = await alertasService.getStats(req.tenantPool!);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -65,7 +65,7 @@ export async function getStats(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
export async function markAllAsRead(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await alertasService.markAllAsRead(req.tenantSchema!);
|
||||
await alertasService.markAllAsRead(req.tenantPool!);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as calendarioService from '../services/calendario.service.js';
|
||||
|
||||
export async function getEventos(req: Request, res: Response, next: NextFunction) {
|
||||
@@ -7,7 +7,7 @@ export async function getEventos(req: Request, res: Response, next: NextFunction
|
||||
const añoNum = parseInt(año as string) || new Date().getFullYear();
|
||||
const mesNum = mes ? parseInt(mes as string) : undefined;
|
||||
|
||||
const eventos = await calendarioService.getEventos(req.tenantSchema!, añoNum, mesNum);
|
||||
const eventos = await calendarioService.getEventos(req.tenantPool!, añoNum, mesNum);
|
||||
res.json(eventos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -17,7 +17,7 @@ export async function getEventos(req: Request, res: Response, next: NextFunction
|
||||
export async function getProximos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const dias = parseInt(req.query.dias as string) || 30;
|
||||
const eventos = await calendarioService.getProximosEventos(req.tenantSchema!, dias);
|
||||
const eventos = await calendarioService.getProximosEventos(req.tenantPool!, dias);
|
||||
res.json(eventos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -26,7 +26,7 @@ export async function getProximos(req: Request, res: Response, next: NextFunctio
|
||||
|
||||
export async function createEvento(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const evento = await calendarioService.createEvento(req.tenantSchema!, req.body);
|
||||
const evento = await calendarioService.createEvento(req.tenantPool!, req.body);
|
||||
res.status(201).json(evento);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -35,7 +35,7 @@ export async function createEvento(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function updateEvento(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(String(req.params.id)), req.body);
|
||||
const evento = await calendarioService.updateEvento(req.tenantPool!, parseInt(String(req.params.id)), req.body);
|
||||
res.json(evento);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -44,7 +44,7 @@ export async function updateEvento(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function deleteEvento(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(String(req.params.id)));
|
||||
await calendarioService.deleteEvento(req.tenantPool!, parseInt(String(req.params.id)));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { CfdiFilters } from '@horux/shared';
|
||||
|
||||
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const filters: CfdiFilters = {
|
||||
@@ -22,7 +22,7 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
|
||||
limit: parseInt(req.query.limit as string) || 20,
|
||||
};
|
||||
|
||||
const result = await cfdiService.getCfdis(req.tenantSchema, filters);
|
||||
const result = await cfdiService.getCfdis(req.tenantPool, filters);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -31,11 +31,11 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const cfdi = await cfdiService.getCfdiById(req.tenantSchema, String(req.params.id));
|
||||
const cfdi = await cfdiService.getCfdiById(req.tenantPool, String(req.params.id));
|
||||
|
||||
if (!cfdi) {
|
||||
return next(new AppError(404, 'CFDI no encontrado'));
|
||||
@@ -49,11 +49,11 @@ export async function getCfdiById(req: Request, res: Response, next: NextFunctio
|
||||
|
||||
export async function getXml(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const xml = await cfdiService.getXmlById(req.tenantSchema, String(req.params.id));
|
||||
const xml = await cfdiService.getXmlById(req.tenantPool, String(req.params.id));
|
||||
|
||||
if (!xml) {
|
||||
return next(new AppError(404, 'XML no encontrado para este CFDI'));
|
||||
@@ -69,8 +69,8 @@ export async function getXml(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
export async function getEmisores(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const search = (req.query.search as string) || '';
|
||||
@@ -78,7 +78,7 @@ export async function getEmisores(req: Request, res: Response, next: NextFunctio
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const emisores = await cfdiService.getEmisores(req.tenantSchema, search);
|
||||
const emisores = await cfdiService.getEmisores(req.tenantPool, search);
|
||||
res.json(emisores);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -87,8 +87,8 @@ export async function getEmisores(req: Request, res: Response, next: NextFunctio
|
||||
|
||||
export async function getReceptores(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const search = (req.query.search as string) || '';
|
||||
@@ -96,7 +96,7 @@ export async function getReceptores(req: Request, res: Response, next: NextFunct
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const receptores = await cfdiService.getReceptores(req.tenantSchema, search);
|
||||
const receptores = await cfdiService.getReceptores(req.tenantPool, search);
|
||||
res.json(receptores);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -105,14 +105,14 @@ export async function getReceptores(req: Request, res: Response, next: NextFunct
|
||||
|
||||
export async function getResumen(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
|
||||
const resumen = await cfdiService.getResumenCfdis(req.tenantSchema, año, mes);
|
||||
const resumen = await cfdiService.getResumenCfdis(req.tenantPool, año, mes);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -121,16 +121,15 @@ export async function getResumen(req: Request, res: Response, next: NextFunction
|
||||
|
||||
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
// Only admin and contador can create CFDIs
|
||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
|
||||
}
|
||||
|
||||
const cfdi = await cfdiService.createCfdi(req.tenantSchema, req.body);
|
||||
const cfdi = await cfdiService.createCfdi(req.tenantPool, req.body);
|
||||
res.status(201).json(cfdi);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('duplicate')) {
|
||||
@@ -142,8 +141,8 @@ export async function createCfdi(req: Request, res: Response, next: NextFunction
|
||||
|
||||
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||
@@ -160,9 +159,9 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
|
||||
totalFiles: req.body.totalFiles || req.body.cfdis.length
|
||||
};
|
||||
|
||||
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs para schema ${req.tenantSchema}`);
|
||||
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs`);
|
||||
|
||||
const result = await cfdiService.createManyCfdisBatch(req.tenantSchema, req.body.cfdis);
|
||||
const result = await cfdiService.createManyCfdisBatch(req.tenantPool, req.body.cfdis);
|
||||
|
||||
res.status(201).json({
|
||||
message: `Lote ${batchInfo.batchNumber} procesado`,
|
||||
@@ -171,7 +170,7 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
|
||||
inserted: result.inserted,
|
||||
duplicates: result.duplicates,
|
||||
errors: result.errors,
|
||||
errorMessages: result.errorMessages.slice(0, 5) // Limit error messages
|
||||
errorMessages: result.errorMessages.slice(0, 5)
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[CFDI Bulk Error]', error.message, error.stack);
|
||||
@@ -181,15 +180,15 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
|
||||
|
||||
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
|
||||
}
|
||||
|
||||
await cfdiService.deleteCfdi(req.tenantSchema, String(req.params.id));
|
||||
await cfdiService.deleteCfdi(req.tenantPool, String(req.params.id));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -4,14 +4,14 @@ import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
export async function getKpis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
|
||||
const kpis = await dashboardService.getKpis(req.tenantSchema, año, mes);
|
||||
const kpis = await dashboardService.getKpis(req.tenantPool, año, mes);
|
||||
res.json(kpis);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -20,13 +20,13 @@ export async function getKpis(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
|
||||
const data = await dashboardService.getIngresosEgresos(req.tenantSchema, año);
|
||||
const data = await dashboardService.getIngresosEgresos(req.tenantPool, año);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -35,14 +35,14 @@ export async function getIngresosEgresos(req: Request, res: Response, next: Next
|
||||
|
||||
export async function getResumenFiscal(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
|
||||
const resumen = await dashboardService.getResumenFiscal(req.tenantSchema, año, mes);
|
||||
const resumen = await dashboardService.getResumenFiscal(req.tenantPool, año, mes);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -51,13 +51,13 @@ export async function getResumenFiscal(req: Request, res: Response, next: NextFu
|
||||
|
||||
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const limit = parseInt(req.query.limit as string) || 5;
|
||||
|
||||
const alertas = await dashboardService.getAlertas(req.tenantSchema, limit);
|
||||
const alertas = await dashboardService.getAlertas(req.tenantPool, limit);
|
||||
res.json(alertas);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as exportService from '../services/export.service.js';
|
||||
|
||||
export async function exportCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { tipo, estado, fechaInicio, fechaFin } = req.query;
|
||||
const buffer = await exportService.exportCfdisToExcel(req.tenantSchema!, {
|
||||
const buffer = await exportService.exportCfdisToExcel(req.tenantPool!, {
|
||||
tipo: tipo as string,
|
||||
estado: estado as string,
|
||||
fechaInicio: fechaInicio as string,
|
||||
@@ -27,7 +27,7 @@ export async function exportReporte(req: Request, res: Response, next: NextFunct
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
const buffer = await exportService.exportReporteToExcel(
|
||||
req.tenantSchema!,
|
||||
req.tenantPool!,
|
||||
tipo as 'estado-resultados' | 'flujo-efectivo',
|
||||
inicio,
|
||||
fin
|
||||
|
||||
@@ -4,12 +4,12 @@ import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const data = await impuestosService.getIvaMensual(req.tenantSchema, año);
|
||||
const data = await impuestosService.getIvaMensual(req.tenantPool, año);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -18,14 +18,14 @@ export async function getIvaMensual(req: Request, res: Response, next: NextFunct
|
||||
|
||||
export async function getResumenIva(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
|
||||
const resumen = await impuestosService.getResumenIva(req.tenantSchema, año, mes);
|
||||
const resumen = await impuestosService.getResumenIva(req.tenantPool, año, mes);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -34,12 +34,12 @@ export async function getResumenIva(req: Request, res: Response, next: NextFunct
|
||||
|
||||
export async function getIsrMensual(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const data = await impuestosService.getIsrMensual(req.tenantSchema, año);
|
||||
const data = await impuestosService.getIsrMensual(req.tenantPool, año);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -48,14 +48,14 @@ export async function getIsrMensual(req: Request, res: Response, next: NextFunct
|
||||
|
||||
export async function getResumenIsr(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
|
||||
const resumen = await impuestosService.getResumenIsr(req.tenantSchema, año, mes);
|
||||
const resumen = await impuestosService.getResumenIsr(req.tenantPool, año, mes);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as reportesService from '../services/reportes.service.js';
|
||||
|
||||
export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) {
|
||||
@@ -8,8 +8,7 @@ export async function getEstadoResultados(req: Request, res: Response, next: Nex
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
console.log('[reportes] getEstadoResultados - schema:', req.tenantSchema, 'inicio:', inicio, 'fin:', fin);
|
||||
const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
|
||||
const data = await reportesService.getEstadoResultados(req.tenantPool!, inicio, fin);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('[reportes] Error en getEstadoResultados:', error);
|
||||
@@ -24,7 +23,7 @@ export async function getFlujoEfectivo(req: Request, res: Response, next: NextFu
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
const data = await reportesService.getFlujoEfectivo(req.tenantSchema!, inicio, fin);
|
||||
const data = await reportesService.getFlujoEfectivo(req.tenantPool!, inicio, fin);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -34,7 +33,7 @@ export async function getFlujoEfectivo(req: Request, res: Response, next: NextFu
|
||||
export async function getComparativo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const data = await reportesService.getComparativo(req.tenantSchema!, año);
|
||||
const data = await reportesService.getComparativo(req.tenantPool!, año);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -49,7 +48,7 @@ export async function getConcentradoRfc(req: Request, res: Response, next: NextF
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente';
|
||||
|
||||
const data = await reportesService.getConcentradoRfc(req.tenantSchema!, inicio, fin, tipoRfc);
|
||||
const data = await reportesService.getConcentradoRfc(req.tenantPool!, inicio, fin, tipoRfc);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
51
apps/api/src/controllers/subscription.controller.ts
Normal file
51
apps/api/src/controllers/subscription.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as subscriptionService from '../services/payment/subscription.service.js';
|
||||
|
||||
export async function getSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = String(req.params.tenantId);
|
||||
const subscription = await subscriptionService.getActiveSubscription(tenantId);
|
||||
if (!subscription) {
|
||||
return res.status(404).json({ message: 'No se encontró suscripción' });
|
||||
}
|
||||
res.json(subscription);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePaymentLink(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = String(req.params.tenantId);
|
||||
const result = await subscriptionService.generatePaymentLink(tenantId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAsPaid(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = String(req.params.tenantId);
|
||||
const { amount } = req.body;
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return res.status(400).json({ message: 'Monto inválido' });
|
||||
}
|
||||
|
||||
const payment = await subscriptionService.markAsPaidManually(tenantId, amount);
|
||||
res.json(payment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPayments(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = String(req.params.tenantId);
|
||||
const payments = await subscriptionService.getPaymentHistory(tenantId);
|
||||
res.json(payments);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
@@ -39,21 +39,24 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
||||
throw new AppError(403, 'Solo administradores pueden crear clientes');
|
||||
}
|
||||
|
||||
const { nombre, rfc, plan, cfdiLimit, usersLimit } = req.body;
|
||||
const { nombre, rfc, plan, cfdiLimit, usersLimit, adminEmail, adminNombre, amount } = req.body;
|
||||
|
||||
if (!nombre || !rfc) {
|
||||
throw new AppError(400, 'Nombre y RFC son requeridos');
|
||||
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
||||
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
||||
}
|
||||
|
||||
const tenant = await tenantsService.createTenant({
|
||||
const result = await tenantsService.createTenant({
|
||||
nombre,
|
||||
rfc,
|
||||
plan,
|
||||
cfdiLimit,
|
||||
usersLimit,
|
||||
adminEmail,
|
||||
adminNombre,
|
||||
amount: amount || 0,
|
||||
});
|
||||
|
||||
res.status(201).json(tenant);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
95
apps/api/src/controllers/webhook.controller.ts
Normal file
95
apps/api/src/controllers/webhook.controller.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as mpService from '../services/payment/mercadopago.service.js';
|
||||
import * as subscriptionService from '../services/payment/subscription.service.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { type, data } = req.body;
|
||||
const xSignature = req.headers['x-signature'] as string;
|
||||
const xRequestId = req.headers['x-request-id'] as string;
|
||||
|
||||
// Verify webhook signature
|
||||
if (xSignature && xRequestId && data?.id) {
|
||||
const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id));
|
||||
if (!isValid) {
|
||||
console.warn('[WEBHOOK] Invalid MercadoPago signature');
|
||||
return res.status(401).json({ message: 'Invalid signature' });
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'payment') {
|
||||
await handlePaymentNotification(String(data.id));
|
||||
} else if (type === 'subscription_preapproval') {
|
||||
await handlePreapprovalNotification(String(data.id));
|
||||
}
|
||||
|
||||
// Always respond 200 to acknowledge receipt
|
||||
res.status(200).json({ received: true });
|
||||
} catch (error) {
|
||||
console.error('[WEBHOOK] Error processing MercadoPago webhook:', error);
|
||||
// Still respond 200 to prevent retries for processing errors
|
||||
res.status(200).json({ received: true, error: 'processing_error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaymentNotification(paymentId: string) {
|
||||
const payment = await mpService.getPaymentDetails(paymentId);
|
||||
|
||||
if (!payment.externalReference) {
|
||||
console.warn('[WEBHOOK] Payment without external_reference:', paymentId);
|
||||
return;
|
||||
}
|
||||
|
||||
const tenantId = payment.externalReference;
|
||||
|
||||
// Find the subscription for this tenant
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
console.warn('[WEBHOOK] No subscription found for tenant:', tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
await subscriptionService.recordPayment({
|
||||
tenantId,
|
||||
subscriptionId: subscription.id,
|
||||
mpPaymentId: paymentId,
|
||||
amount: payment.transactionAmount || 0,
|
||||
status: payment.status || 'unknown',
|
||||
paymentMethod: payment.paymentMethodId || 'unknown',
|
||||
});
|
||||
|
||||
// If payment approved, ensure subscription is active
|
||||
if (payment.status === 'approved') {
|
||||
await prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { status: 'authorized' },
|
||||
});
|
||||
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||
}
|
||||
|
||||
// Broadcast cache invalidation to PM2 cluster workers
|
||||
if (typeof process.send === 'function') {
|
||||
process.send({ type: 'invalidate-tenant-cache', tenantId });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreapprovalNotification(preapprovalId: string) {
|
||||
const preapproval = await mpService.getPreapproval(preapprovalId);
|
||||
|
||||
if (preapproval.status) {
|
||||
await subscriptionService.updateSubscriptionStatus(preapprovalId, preapproval.status);
|
||||
}
|
||||
|
||||
// Broadcast cache invalidation
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { mpPreapprovalId: preapprovalId },
|
||||
});
|
||||
if (subscription && typeof process.send === 'function') {
|
||||
process.send({ type: 'invalidate-tenant-cache', tenantId: subscription.tenantId });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { app } from './app.js';
|
||||
import { env } from './config/env.js';
|
||||
import { tenantDb } from './config/database.js';
|
||||
import { invalidateTenantCache } from './middlewares/plan-limits.middleware.js';
|
||||
import { startSatSyncJob } from './jobs/sat-sync.job.js';
|
||||
|
||||
const PORT = parseInt(env.PORT, 10);
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`API Server running on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Environment: ${env.NODE_ENV}`);
|
||||
|
||||
@@ -13,3 +15,24 @@ app.listen(PORT, '0.0.0.0', () => {
|
||||
startSatSyncJob();
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown — close all tenant DB pools before exiting
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
console.log(`${signal} received. Shutting down gracefully...`);
|
||||
server.close(() => {
|
||||
console.log('HTTP server closed');
|
||||
});
|
||||
await tenantDb.shutdown();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
// PM2 cluster: cross-worker cache invalidation
|
||||
process.on('message', (msg: any) => {
|
||||
if (msg?.type === 'invalidate-tenant-cache' && msg.tenantId) {
|
||||
tenantDb.invalidatePool(msg.tenantId);
|
||||
invalidateTenantCache(msg.tenantId);
|
||||
}
|
||||
});
|
||||
|
||||
37
apps/api/src/middlewares/feature-gate.middleware.ts
Normal file
37
apps/api/src/middlewares/feature-gate.middleware.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { hasFeature, type Plan } from '@horux/shared';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
const planCache = new Map<string, { plan: string; expires: number }>();
|
||||
|
||||
/**
|
||||
* Middleware factory that gates routes based on tenant plan features.
|
||||
* Usage: requireFeature('reportes') — blocks access if tenant's plan lacks the feature.
|
||||
*/
|
||||
export function requireFeature(feature: string) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) return res.status(401).json({ message: 'No autenticado' });
|
||||
|
||||
let plan: string;
|
||||
const cached = planCache.get(req.user.tenantId);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
plan = cached.plan;
|
||||
} else {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: req.user.tenantId },
|
||||
select: { plan: true },
|
||||
});
|
||||
if (!tenant) return res.status(404).json({ message: 'Tenant no encontrado' });
|
||||
plan = tenant.plan;
|
||||
planCache.set(req.user.tenantId, { plan, expires: Date.now() + 5 * 60 * 1000 });
|
||||
}
|
||||
|
||||
if (!hasFeature(plan as Plan, feature)) {
|
||||
return res.status(403).json({
|
||||
message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.',
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
89
apps/api/src/middlewares/plan-limits.middleware.ts
Normal file
89
apps/api/src/middlewares/plan-limits.middleware.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
// Simple in-memory cache with TTL
|
||||
const cache = new Map<string, { data: any; expires: number }>();
|
||||
|
||||
async function getCached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
|
||||
const entry = cache.get(key);
|
||||
if (entry && entry.expires > Date.now()) return entry.data;
|
||||
const data = await fetcher();
|
||||
cache.set(key, { data, expires: Date.now() + ttlMs });
|
||||
return data;
|
||||
}
|
||||
|
||||
export function invalidateTenantCache(tenantId: string) {
|
||||
for (const key of cache.keys()) {
|
||||
if (key.includes(tenantId)) cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if tenant has an active subscription before allowing write operations
|
||||
*/
|
||||
export async function checkPlanLimits(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.user) return next();
|
||||
|
||||
// Admin impersonation bypasses subscription check
|
||||
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const subscription = await getCached(
|
||||
`sub:${req.user.tenantId}`,
|
||||
5 * 60 * 1000,
|
||||
() => prisma.subscription.findFirst({
|
||||
where: { tenantId: req.user!.tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
);
|
||||
|
||||
const allowedStatuses = ['authorized', 'pending'];
|
||||
|
||||
if (!subscription || !allowedStatuses.includes(subscription.status)) {
|
||||
// Allow GET requests even with inactive subscription (read-only access)
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(403).json({
|
||||
message: 'Suscripción inactiva. Contacta soporte para reactivar.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if tenant has room for more CFDIs before allowing CFDI creation
|
||||
*/
|
||||
export async function checkCfdiLimit(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.user || !req.tenantPool) return next();
|
||||
|
||||
const tenant = await getCached(
|
||||
`tenant:${req.user.tenantId}`,
|
||||
5 * 60 * 1000,
|
||||
() => prisma.tenant.findUnique({
|
||||
where: { id: req.user!.tenantId },
|
||||
select: { cfdiLimit: true },
|
||||
})
|
||||
);
|
||||
|
||||
if (!tenant || tenant.cfdiLimit === -1) return next(); // unlimited
|
||||
|
||||
const cfdiCount = await getCached(
|
||||
`cfdi-count:${req.user.tenantId}`,
|
||||
5 * 60 * 1000,
|
||||
async () => {
|
||||
const result = await req.tenantPool!.query('SELECT COUNT(*) FROM cfdis');
|
||||
return parseInt(result.rows[0].count);
|
||||
}
|
||||
);
|
||||
|
||||
const newCount = Array.isArray(req.body) ? req.body.length : 1;
|
||||
if (cfdiCount + newCount > tenant.cfdiLimit) {
|
||||
return res.status(403).json({
|
||||
message: `Límite de CFDIs alcanzado (${cfdiCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -1,48 +1,55 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { AppError } from './error.middleware.js';
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
tenantSchema?: string;
|
||||
tenantPool?: Pool;
|
||||
viewingTenantId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.user) {
|
||||
return next(new AppError(401, 'No autenticado'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if admin is viewing a different tenant
|
||||
const viewTenantId = req.headers['x-view-tenant'] as string | undefined;
|
||||
let tenantId = req.user.tenantId;
|
||||
|
||||
// Only admins can view other tenants
|
||||
if (viewTenantId && req.user.role === 'admin') {
|
||||
tenantId = viewTenantId;
|
||||
req.viewingTenantId = viewTenantId;
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'No autenticado' });
|
||||
}
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { schemaName: true, active: true },
|
||||
let tenantId = req.user.tenantId;
|
||||
let databaseName = req.user.databaseName;
|
||||
|
||||
// Admin impersonation via X-View-Tenant header
|
||||
const viewTenantHeader = req.headers['x-view-tenant'] as string;
|
||||
if (viewTenantHeader && req.user.role === 'admin') {
|
||||
const viewedTenant = await prisma.tenant.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: viewTenantHeader },
|
||||
{ rfc: viewTenantHeader },
|
||||
],
|
||||
},
|
||||
select: { id: true, databaseName: true, active: true },
|
||||
});
|
||||
|
||||
if (!tenant || !tenant.active) {
|
||||
return next(new AppError(403, 'Tenant no encontrado o inactivo'));
|
||||
if (!viewedTenant) {
|
||||
return res.status(404).json({ message: 'Tenant no encontrado' });
|
||||
}
|
||||
|
||||
req.tenantSchema = tenant.schemaName;
|
||||
if (!viewedTenant.active) {
|
||||
return res.status(403).json({ message: 'Tenant inactivo' });
|
||||
}
|
||||
|
||||
// Set search_path for this request
|
||||
await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.schemaName}", public`);
|
||||
tenantId = viewedTenant.id;
|
||||
databaseName = viewedTenant.databaseName;
|
||||
req.viewingTenantId = viewedTenant.id;
|
||||
}
|
||||
|
||||
req.tenantPool = tenantDb.getPool(tenantId, databaseName);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(new AppError(500, 'Error al configurar tenant'));
|
||||
console.error('[TenantMiddleware] Error:', error);
|
||||
return res.status(500).json({ message: 'Error al resolver tenant' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
|
||||
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
|
||||
import * as alertasController from '../controllers/alertas.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
router.use(checkPlanLimits);
|
||||
router.use(requireFeature('alertas'));
|
||||
|
||||
router.get('/', alertasController.getAlertas);
|
||||
router.get('/stats', alertasController.getStats);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
|
||||
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
|
||||
import * as calendarioController from '../controllers/calendario.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
router.use(checkPlanLimits);
|
||||
router.use(requireFeature('calendario'));
|
||||
|
||||
router.get('/', calendarioController.getEventos);
|
||||
router.get('/proximos', calendarioController.getProximos);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits, checkCfdiLimit } from '../middlewares/plan-limits.middleware.js';
|
||||
import * as cfdiController from '../controllers/cfdi.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
router.use(checkPlanLimits);
|
||||
|
||||
router.get('/', cfdiController.getCfdis);
|
||||
router.get('/resumen', cfdiController.getResumen);
|
||||
@@ -14,8 +16,8 @@ router.get('/emisores', cfdiController.getEmisores);
|
||||
router.get('/receptores', cfdiController.getReceptores);
|
||||
router.get('/:id', cfdiController.getCfdiById);
|
||||
router.get('/:id/xml', cfdiController.getXml);
|
||||
router.post('/', cfdiController.createCfdi);
|
||||
router.post('/bulk', cfdiController.createManyCfdis);
|
||||
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
||||
router.post('/bulk', checkCfdiLimit, cfdiController.createManyCfdis);
|
||||
router.delete('/:id', cfdiController.deleteCfdi);
|
||||
|
||||
export { router as cfdiRoutes };
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
|
||||
import * as dashboardController from '../controllers/dashboard.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
router.use(checkPlanLimits);
|
||||
|
||||
router.get('/kpis', dashboardController.getKpis);
|
||||
router.get('/ingresos-egresos', dashboardController.getIngresosEgresos);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
|
||||
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
|
||||
import * as reportesController from '../controllers/reportes.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
router.use(checkPlanLimits);
|
||||
router.use(requireFeature('reportes'));
|
||||
|
||||
router.get('/estado-resultados', reportesController.getEstadoResultados);
|
||||
router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);
|
||||
|
||||
16
apps/api/src/routes/subscription.routes.ts
Normal file
16
apps/api/src/routes/subscription.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import * as subscriptionController from '../controllers/subscription.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// All endpoints require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// Admin subscription management
|
||||
router.get('/:tenantId', subscriptionController.getSubscription);
|
||||
router.post('/:tenantId/generate-link', subscriptionController.generatePaymentLink);
|
||||
router.post('/:tenantId/mark-paid', subscriptionController.markAsPaid);
|
||||
router.get('/:tenantId/payments', subscriptionController.getPayments);
|
||||
|
||||
export { router as subscriptionRoutes };
|
||||
9
apps/api/src/routes/webhook.routes.ts
Normal file
9
apps/api/src/routes/webhook.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { handleMercadoPagoWebhook } from '../controllers/webhook.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Public endpoint — no auth middleware
|
||||
router.post('/mercadopago', handleMercadoPagoWebhook);
|
||||
|
||||
export { router as webhookRoutes };
|
||||
@@ -1,8 +1,8 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
|
||||
|
||||
export async function getAlertas(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
|
||||
): Promise<AlertaFull[]> {
|
||||
let whereClause = 'WHERE 1=1';
|
||||
@@ -22,43 +22,43 @@ export async function getAlertas(
|
||||
params.push(filters.prioridad);
|
||||
}
|
||||
|
||||
const alertas = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
FROM "${schema}".alertas
|
||||
FROM alertas
|
||||
${whereClause}
|
||||
ORDER BY
|
||||
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||
created_at DESC
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
return alertas;
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getAlertaById(schema: string, id: number): Promise<AlertaFull | null> {
|
||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
export async function getAlertaById(pool: Pool, id: number): Promise<AlertaFull | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
FROM "${schema}".alertas
|
||||
FROM alertas
|
||||
WHERE id = $1
|
||||
`, id);
|
||||
return alerta || null;
|
||||
`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function createAlerta(schema: string, data: AlertaCreate): Promise<AlertaFull> {
|
||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
INSERT INTO "${schema}".alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||
export async function createAlerta(pool: Pool, data: AlertaCreate): Promise<AlertaFull> {
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
`, data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null);
|
||||
return alerta;
|
||||
`, [data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function updateAlerta(schema: string, id: number, data: AlertaUpdate): Promise<AlertaFull> {
|
||||
export async function updateAlerta(pool: Pool, id: number, data: AlertaUpdate): Promise<AlertaFull> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
@@ -74,35 +74,35 @@ export async function updateAlerta(schema: string, id: number, data: AlertaUpdat
|
||||
|
||||
params.push(id);
|
||||
|
||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
UPDATE "${schema}".alertas
|
||||
const { rows } = await pool.query(`
|
||||
UPDATE alertas
|
||||
SET ${sets.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
return alerta;
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function deleteAlerta(schema: string, id: number): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id);
|
||||
export async function deleteAlerta(pool: Pool, id: number): Promise<void> {
|
||||
await pool.query(`DELETE FROM alertas WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
export async function getStats(schema: string): Promise<AlertasStats> {
|
||||
const [stats] = await prisma.$queryRawUnsafe<AlertasStats[]>(`
|
||||
export async function getStats(pool: Pool): Promise<AlertasStats> {
|
||||
const { rows: [stats] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*)::int as total,
|
||||
COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas",
|
||||
COUNT(CASE WHEN prioridad = 'alta' AND resuelta = false THEN 1 END)::int as alta,
|
||||
COUNT(CASE WHEN prioridad = 'media' AND resuelta = false THEN 1 END)::int as media,
|
||||
COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
|
||||
FROM "${schema}".alertas
|
||||
FROM alertas
|
||||
`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
export async function markAllAsRead(schema: string): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`);
|
||||
export async function markAllAsRead(pool: Pool): Promise<void> {
|
||||
await pool.query(`UPDATE alertas SET leida = true WHERE leida = false`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { hashPassword, verifyPassword } from '../utils/password.js';
|
||||
import { generateAccessToken, generateRefreshToken, verifyToken } from '../utils/token.js';
|
||||
import { createTenantSchema } from '../utils/schema-manager.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { PLANS } from '@horux/shared';
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';
|
||||
@@ -23,21 +22,20 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
throw new AppError(400, 'El RFC ya está registrado');
|
||||
}
|
||||
|
||||
const schemaName = `tenant_${data.empresa.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
// Provision a dedicated database for this tenant
|
||||
const databaseName = await tenantDb.provisionDatabase(data.empresa.rfc);
|
||||
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: data.empresa.nombre,
|
||||
rfc: data.empresa.rfc.toUpperCase(),
|
||||
plan: 'starter',
|
||||
schemaName,
|
||||
databaseName,
|
||||
cfdiLimit: PLANS.starter.cfdiLimit,
|
||||
usersLimit: PLANS.starter.usersLimit,
|
||||
},
|
||||
});
|
||||
|
||||
await createTenantSchema(schemaName);
|
||||
|
||||
const passwordHash = await hashPassword(data.usuario.password);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -54,7 +52,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: tenant.id,
|
||||
schemaName: tenant.schemaName,
|
||||
databaseName: tenant.databaseName,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
@@ -79,6 +77,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.nombre,
|
||||
tenantRfc: tenant.rfc,
|
||||
plan: tenant.plan,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -117,7 +116,7 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
schemaName: user.tenant.schemaName,
|
||||
databaseName: user.tenant.databaseName,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
@@ -142,6 +141,7 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
tenantId: user.tenantId,
|
||||
tenantName: user.tenant.nombre,
|
||||
tenantRfc: user.tenant.rfc,
|
||||
plan: user.tenant.plan,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
schemaName: user.tenant.schemaName,
|
||||
databaseName: user.tenant.databaseName,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(newTokenPayload);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
||||
|
||||
export async function getEventos(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
año: number,
|
||||
mes?: number
|
||||
): Promise<EventoFiscal[]> {
|
||||
@@ -14,49 +14,49 @@ export async function getEventos(
|
||||
params.push(mes);
|
||||
}
|
||||
|
||||
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".calendario_fiscal
|
||||
FROM calendario_fiscal
|
||||
${whereClause}
|
||||
ORDER BY fecha_limite ASC
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
return eventos;
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getProximosEventos(schema: string, dias = 30): Promise<EventoFiscal[]> {
|
||||
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
export async function getProximosEventos(pool: Pool, dias = 30): Promise<EventoFiscal[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".calendario_fiscal
|
||||
FROM calendario_fiscal
|
||||
WHERE completado = false
|
||||
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '${dias} days'
|
||||
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + $1 * INTERVAL '1 day'
|
||||
ORDER BY fecha_limite ASC
|
||||
`);
|
||||
`, [dias]);
|
||||
|
||||
return eventos;
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function createEvento(schema: string, data: EventoCreate): Promise<EventoFiscal> {
|
||||
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
INSERT INTO "${schema}".calendario_fiscal
|
||||
export async function createEvento(pool: Pool, data: EventoCreate): Promise<EventoFiscal> {
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO calendario_fiscal
|
||||
(titulo, descripcion, tipo, fecha_limite, recurrencia, notas)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
`, data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null);
|
||||
`, [data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null]);
|
||||
|
||||
return evento;
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function updateEvento(schema: string, id: number, data: EventoUpdate): Promise<EventoFiscal> {
|
||||
export async function updateEvento(pool: Pool, id: number, data: EventoUpdate): Promise<EventoFiscal> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
@@ -84,19 +84,19 @@ export async function updateEvento(schema: string, id: number, data: EventoUpdat
|
||||
|
||||
params.push(id);
|
||||
|
||||
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
UPDATE "${schema}".calendario_fiscal
|
||||
const { rows } = await pool.query(`
|
||||
UPDATE calendario_fiscal
|
||||
SET ${sets.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
return evento;
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function deleteEvento(schema: string, id: number): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id);
|
||||
export async function deleteEvento(pool: Pool, id: number): Promise<void> {
|
||||
await pool.query(`DELETE FROM calendario_fiscal WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
|
||||
|
||||
export async function getCfdis(schema: string, filters: CfdiFilters): Promise<CfdiListResponse> {
|
||||
export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiListResponse> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -50,9 +50,8 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
||||
params.push(`%${filters.search}%`);
|
||||
}
|
||||
|
||||
// Combinar COUNT con la query principal usando window function
|
||||
params.push(limit, offset);
|
||||
const dataWithCount = await prisma.$queryRawUnsafe<(Cfdi & { total_count: number })[]>(`
|
||||
const { rows: dataWithCount } = await pool.query(`
|
||||
SELECT
|
||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||
@@ -65,14 +64,14 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
created_at as "createdAt",
|
||||
COUNT(*) OVER() as total_count
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
${whereClause}
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
const total = Number(dataWithCount[0]?.total_count || 0);
|
||||
const data = dataWithCount.map(({ total_count, ...cfdi }) => cfdi) as Cfdi[];
|
||||
const data = dataWithCount.map(({ total_count, ...cfdi }: any) => cfdi) as Cfdi[];
|
||||
|
||||
return {
|
||||
data,
|
||||
@@ -83,8 +82,8 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
|
||||
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||
export async function getCfdiById(pool: Pool, id: string): Promise<Cfdi | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||
@@ -97,19 +96,19 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
xml_original as "xmlOriginal",
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE id = $1::uuid
|
||||
`, id);
|
||||
`, [id]);
|
||||
|
||||
return result[0] || null;
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getXmlById(schema: string, id: string): Promise<string | null> {
|
||||
const result = await prisma.$queryRawUnsafe<[{ xml_original: string | null }]>(`
|
||||
SELECT xml_original FROM "${schema}".cfdis WHERE id = $1::uuid
|
||||
`, id);
|
||||
export async function getXmlById(pool: Pool, id: string): Promise<string | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT xml_original FROM cfdis WHERE id = $1::uuid
|
||||
`, [id]);
|
||||
|
||||
return result[0]?.xml_original || null;
|
||||
return rows[0]?.xml_original || null;
|
||||
}
|
||||
|
||||
export interface CreateCfdiData {
|
||||
@@ -139,18 +138,15 @@ export interface CreateCfdiData {
|
||||
pdfUrl?: string;
|
||||
}
|
||||
|
||||
export async function createCfdi(schema: string, data: CreateCfdiData): Promise<Cfdi> {
|
||||
// Validate required fields
|
||||
export async function createCfdi(pool: Pool, data: CreateCfdiData): Promise<Cfdi> {
|
||||
if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido');
|
||||
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
|
||||
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
|
||||
if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido');
|
||||
|
||||
// Parse dates safely - handle YYYY-MM-DD format explicitly
|
||||
let fechaEmision: Date;
|
||||
let fechaTimbrado: Date;
|
||||
|
||||
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
|
||||
const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||
? `${data.fechaEmision}T12:00:00`
|
||||
: data.fechaEmision;
|
||||
@@ -173,8 +169,8 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
|
||||
throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`);
|
||||
}
|
||||
|
||||
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||
INSERT INTO "${schema}".cfdis (
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO cfdis (
|
||||
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||
@@ -191,7 +187,7 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
|
||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
created_at as "createdAt"
|
||||
`,
|
||||
`, [
|
||||
data.uuidFiscal,
|
||||
data.tipo || 'ingreso',
|
||||
data.serie || null,
|
||||
@@ -216,9 +212,9 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
|
||||
data.estado || 'vigente',
|
||||
data.xmlUrl || null,
|
||||
data.pdfUrl || null
|
||||
);
|
||||
]);
|
||||
|
||||
return result[0];
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export interface BatchInsertResult {
|
||||
@@ -228,14 +224,12 @@ export interface BatchInsertResult {
|
||||
errorMessages: string[];
|
||||
}
|
||||
|
||||
// Optimized batch insert using multi-row INSERT
|
||||
export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> {
|
||||
const result = await createManyCfdisBatch(schema, cfdis);
|
||||
export async function createManyCfdis(pool: Pool, cfdis: CreateCfdiData[]): Promise<number> {
|
||||
const result = await createManyCfdisBatch(pool, cfdis);
|
||||
return result.inserted;
|
||||
}
|
||||
|
||||
// New optimized batch insert with detailed results
|
||||
export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||
export async function createManyCfdisBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||
const result: BatchInsertResult = {
|
||||
inserted: 0,
|
||||
duplicates: 0,
|
||||
@@ -245,19 +239,17 @@ export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData
|
||||
|
||||
if (cfdis.length === 0) return result;
|
||||
|
||||
// Process in batches of 500 for optimal performance
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
for (let batchStart = 0; batchStart < cfdis.length; batchStart += BATCH_SIZE) {
|
||||
const batch = cfdis.slice(batchStart, batchStart + BATCH_SIZE);
|
||||
|
||||
try {
|
||||
const batchResult = await insertBatch(schema, batch);
|
||||
const batchResult = await insertBatch(pool, batch);
|
||||
result.inserted += batchResult.inserted;
|
||||
result.duplicates += batchResult.duplicates;
|
||||
} catch (error: any) {
|
||||
// If batch fails, try individual inserts for this batch
|
||||
const individualResult = await insertIndividually(schema, batch);
|
||||
const individualResult = await insertIndividually(pool, batch);
|
||||
result.inserted += individualResult.inserted;
|
||||
result.duplicates += individualResult.duplicates;
|
||||
result.errors += individualResult.errors;
|
||||
@@ -268,17 +260,14 @@ export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData
|
||||
return result;
|
||||
}
|
||||
|
||||
// Insert a batch using multi-row INSERT with ON CONFLICT
|
||||
async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> {
|
||||
async function insertBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> {
|
||||
if (cfdis.length === 0) return { inserted: 0, duplicates: 0 };
|
||||
|
||||
// Build the VALUES part of the query
|
||||
const values: any[] = [];
|
||||
const valuePlaceholders: string[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const cfdi of cfdis) {
|
||||
// Parse dates
|
||||
const fechaEmision = parseDate(cfdi.fechaEmision);
|
||||
const fechaTimbrado = cfdi.fechaTimbrado ? parseDate(cfdi.fechaTimbrado) : fechaEmision;
|
||||
|
||||
@@ -322,9 +311,8 @@ async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ i
|
||||
return { inserted: 0, duplicates: 0 };
|
||||
}
|
||||
|
||||
// Use ON CONFLICT to handle duplicates gracefully
|
||||
const query = `
|
||||
INSERT INTO "${schema}".cfdis (
|
||||
INSERT INTO cfdis (
|
||||
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||
@@ -333,15 +321,12 @@ async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ i
|
||||
ON CONFLICT (uuid_fiscal) DO NOTHING
|
||||
`;
|
||||
|
||||
await prisma.$executeRawUnsafe(query, ...values);
|
||||
await pool.query(query, values);
|
||||
|
||||
// We can't know exactly how many were inserted vs duplicates with DO NOTHING
|
||||
// Return optimistic count, duplicates will be 0 (they're silently skipped)
|
||||
return { inserted: valuePlaceholders.length, duplicates: 0 };
|
||||
}
|
||||
|
||||
// Fallback: insert individually when batch fails
|
||||
async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||
async function insertIndividually(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||
const result: BatchInsertResult = {
|
||||
inserted: 0,
|
||||
duplicates: 0,
|
||||
@@ -351,7 +336,7 @@ async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Prom
|
||||
|
||||
for (const cfdi of cfdis) {
|
||||
try {
|
||||
await createCfdi(schema, cfdi);
|
||||
await createCfdi(pool, cfdi);
|
||||
result.inserted++;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || 'Error desconocido';
|
||||
@@ -369,11 +354,9 @@ async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Prom
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper to parse dates safely
|
||||
function parseDate(dateStr: string): Date | null {
|
||||
if (!dateStr) return null;
|
||||
|
||||
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
|
||||
const normalized = dateStr.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||
? `${dateStr}T12:00:00`
|
||||
: dateStr;
|
||||
@@ -382,41 +365,34 @@ function parseDate(dateStr: string): Date | null {
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export async function deleteCfdi(schema: string, id: string): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
|
||||
export async function deleteCfdi(pool: Pool, id: string): Promise<void> {
|
||||
await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
export async function getEmisores(schema: string, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
|
||||
const result = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string }[]>(`
|
||||
export async function getEmisores(pool: Pool, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1
|
||||
ORDER BY nombre_emisor
|
||||
LIMIT $2
|
||||
`, `%${search}%`, limit);
|
||||
return result;
|
||||
`, [`%${search}%`, limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getReceptores(schema: string, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
|
||||
const result = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string }[]>(`
|
||||
export async function getReceptores(pool: Pool, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT DISTINCT rfc_receptor as rfc, nombre_receptor as nombre
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE rfc_receptor ILIKE $1 OR nombre_receptor ILIKE $1
|
||||
ORDER BY nombre_receptor
|
||||
LIMIT $2
|
||||
`, `%${search}%`, limit);
|
||||
return result;
|
||||
`, [`%${search}%`, limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getResumenCfdis(schema: string, año: number, mes: number) {
|
||||
const result = await prisma.$queryRawUnsafe<[{
|
||||
total_ingresos: number;
|
||||
total_egresos: number;
|
||||
count_ingresos: number;
|
||||
count_egresos: number;
|
||||
iva_trasladado: number;
|
||||
iva_acreditable: number;
|
||||
}]>(`
|
||||
export async function getResumenCfdis(pool: Pool, año: number, mes: number) {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as total_ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as total_egresos,
|
||||
@@ -424,13 +400,13 @@ export async function getResumenCfdis(schema: string, año: number, mes: number)
|
||||
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as count_egresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as iva_trasladado,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva_acreditable
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const r = result[0];
|
||||
const r = rows[0];
|
||||
return {
|
||||
totalIngresos: Number(r?.total_ingresos || 0),
|
||||
totalEgresos: Number(r?.total_egresos || 0),
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared';
|
||||
|
||||
export async function getKpis(schema: string, año: number, mes: number): Promise<KpiData> {
|
||||
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
export async function getKpis(pool: Pool, año: number, mes: number): Promise<KpiData> {
|
||||
const { rows: [ingresos] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(total), 0) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'ingreso'
|
||||
AND estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
const { rows: [egresos] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(total), 0) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'egreso'
|
||||
AND estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const [ivaData] = await prisma.$queryRawUnsafe<[{ trasladado: number; acreditable: number }]>(`
|
||||
const { rows: [ivaData] } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const [counts] = await prisma.$queryRawUnsafe<[{ emitidos: number; recibidos: number }]>(`
|
||||
const { rows: [counts] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as emitidos,
|
||||
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as recibidos
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const ingresosVal = Number(ingresos?.total || 0);
|
||||
const egresosVal = Number(egresos?.total || 0);
|
||||
@@ -57,23 +57,23 @@ export async function getKpis(schema: string, año: number, mes: number): Promis
|
||||
};
|
||||
}
|
||||
|
||||
export async function getIngresosEgresos(schema: string, año: number): Promise<IngresosEgresosData[]> {
|
||||
const data = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
|
||||
export async function getIngresosEgresos(pool: Pool, año: number): Promise<IngresosEgresosData[]> {
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT
|
||||
EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
GROUP BY EXTRACT(MONTH FROM fecha_emision)
|
||||
ORDER BY mes
|
||||
`, año);
|
||||
`, [año]);
|
||||
|
||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
|
||||
return meses.map((mes, index) => {
|
||||
const found = data.find(d => d.mes === index + 1);
|
||||
const found = data.find((d: any) => d.mes === index + 1);
|
||||
return {
|
||||
mes,
|
||||
ingresos: Number(found?.ingresos || 0),
|
||||
@@ -82,16 +82,17 @@ export async function getIngresosEgresos(schema: string, año: number): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export async function getResumenFiscal(schema: string, año: number, mes: number): Promise<ResumenFiscal> {
|
||||
const [ivaResult] = await prisma.$queryRawUnsafe<[{ resultado: number; acumulado: number }]>(`
|
||||
SELECT resultado, acumulado FROM "${schema}".iva_mensual
|
||||
export async function getResumenFiscal(pool: Pool, año: number, mes: number): Promise<ResumenFiscal> {
|
||||
const { rows: ivaRows } = await pool.query(`
|
||||
SELECT resultado, acumulado FROM iva_mensual
|
||||
WHERE año = $1 AND mes = $2
|
||||
`, año, mes) || [{ resultado: 0, acumulado: 0 }];
|
||||
`, [año, mes]);
|
||||
const ivaResult = ivaRows[0] || { resultado: 0, acumulado: 0 };
|
||||
|
||||
const [pendientes] = await prisma.$queryRawUnsafe<[{ count: number }]>(`
|
||||
SELECT COUNT(*) as count FROM "${schema}".iva_mensual
|
||||
const { rows: [pendientes] } = await pool.query(`
|
||||
SELECT COUNT(*) as count FROM iva_mensual
|
||||
WHERE año = $1 AND estado = 'pendiente'
|
||||
`, año);
|
||||
`, [año]);
|
||||
|
||||
const resultado = Number(ivaResult?.resultado || 0);
|
||||
const acumulado = Number(ivaResult?.acumulado || 0);
|
||||
@@ -108,19 +109,19 @@ export async function getResumenFiscal(schema: string, año: number, mes: number
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAlertas(schema: string, limit = 5): Promise<Alerta[]> {
|
||||
const alertas = await prisma.$queryRawUnsafe<Alerta[]>(`
|
||||
export async function getAlertas(pool: Pool, limit = 5): Promise<Alerta[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta,
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".alertas
|
||||
FROM alertas
|
||||
WHERE resuelta = false
|
||||
ORDER BY
|
||||
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||
created_at DESC
|
||||
LIMIT $1
|
||||
`, limit);
|
||||
`, [limit]);
|
||||
|
||||
return alertas;
|
||||
return rows;
|
||||
}
|
||||
|
||||
79
apps/api/src/services/email/email.service.ts
Normal file
79
apps/api/src/services/email/email.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createTransport, type Transporter } from 'nodemailer';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
|
||||
function getTransporter(): Transporter {
|
||||
if (!transporter) {
|
||||
if (!env.SMTP_USER || !env.SMTP_PASS) {
|
||||
console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.');
|
||||
return {
|
||||
sendMail: async (opts: any) => {
|
||||
console.log('[EMAIL] Would send:', { to: opts.to, subject: opts.subject });
|
||||
return { messageId: 'mock' };
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
transporter = createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT),
|
||||
secure: false, // STARTTLS
|
||||
auth: {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
async function sendEmail(to: string, subject: string, html: string) {
|
||||
const transport = getTransporter();
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text: html.replace(/<[^>]*>/g, ''),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[EMAIL] Error sending email:', error);
|
||||
// Don't throw — email failure shouldn't break the main flow
|
||||
}
|
||||
}
|
||||
|
||||
export const emailService = {
|
||||
sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string }) => {
|
||||
const { welcomeEmail } = await import('./templates/welcome.js');
|
||||
await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
|
||||
},
|
||||
|
||||
sendFielNotification: async (data: { clienteNombre: string; clienteRfc: string }) => {
|
||||
const { fielNotificationEmail } = await import('./templates/fiel-notification.js');
|
||||
await sendEmail(env.ADMIN_EMAIL, `[${data.clienteNombre}] subió su FIEL`, fielNotificationEmail(data));
|
||||
},
|
||||
|
||||
sendPaymentConfirmed: async (to: string, data: { nombre: string; amount: number; plan: string; date: string }) => {
|
||||
const { paymentConfirmedEmail } = await import('./templates/payment-confirmed.js');
|
||||
await sendEmail(to, 'Confirmación de pago - Horux360', paymentConfirmedEmail(data));
|
||||
},
|
||||
|
||||
sendPaymentFailed: async (to: string, data: { nombre: string; amount: number; plan: string }) => {
|
||||
const { paymentFailedEmail } = await import('./templates/payment-failed.js');
|
||||
await sendEmail(to, 'Problema con tu pago - Horux360', paymentFailedEmail(data));
|
||||
await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
|
||||
},
|
||||
|
||||
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
|
||||
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
|
||||
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));
|
||||
},
|
||||
|
||||
sendSubscriptionCancelled: async (to: string, data: { nombre: string; plan: string }) => {
|
||||
const { subscriptionCancelledEmail } = await import('./templates/subscription-cancelled.js');
|
||||
await sendEmail(to, 'Suscripción cancelada - Horux360', subscriptionCancelledEmail(data));
|
||||
await sendEmail(env.ADMIN_EMAIL, `Suscripción cancelada: ${data.nombre}`, subscriptionCancelledEmail(data));
|
||||
},
|
||||
};
|
||||
35
apps/api/src/services/email/templates/base.ts
Normal file
35
apps/api/src/services/email/templates/base.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export function baseTemplate(content: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:32px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:8px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background-color:#1e293b;padding:24px 32px;text-align:center;">
|
||||
<h1 style="color:#ffffff;margin:0;font-size:24px;">Horux360</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:32px;">
|
||||
${content}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#f8fafc;padding:16px 32px;text-align:center;font-size:12px;color:#94a3b8;">
|
||||
<p style="margin:0;">© ${new Date().getFullYear()} Horux360 - Plataforma Fiscal Inteligente</p>
|
||||
<p style="margin:4px 0 0;">Consultoria Alcaraz Salazar</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
14
apps/api/src/services/email/templates/fiel-notification.ts
Normal file
14
apps/api/src/services/email/templates/fiel-notification.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { baseTemplate } from './base.js';
|
||||
|
||||
export function fielNotificationEmail(data: { clienteNombre: string; clienteRfc: string }): string {
|
||||
return baseTemplate(`
|
||||
<h2 style="color:#1e293b;margin:0 0 16px;">FIEL Subida</h2>
|
||||
<p style="color:#475569;line-height:1.6;">El cliente <strong>${data.clienteNombre}</strong> ha subido su e.firma (FIEL).</p>
|
||||
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
|
||||
<p style="margin:0;color:#334155;"><strong>Empresa:</strong> ${data.clienteNombre}</p>
|
||||
<p style="margin:8px 0 0;color:#334155;"><strong>RFC:</strong> ${data.clienteRfc}</p>
|
||||
<p style="margin:8px 0 0;color:#334155;"><strong>Fecha:</strong> ${new Date().toLocaleString('es-MX')}</p>
|
||||
</div>
|
||||
<p style="color:#475569;line-height:1.6;">Ya puedes iniciar la sincronización de CFDIs para este cliente.</p>
|
||||
`);
|
||||
}
|
||||
15
apps/api/src/services/email/templates/payment-confirmed.ts
Normal file
15
apps/api/src/services/email/templates/payment-confirmed.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { baseTemplate } from './base.js';
|
||||
|
||||
export function paymentConfirmedEmail(data: { nombre: string; amount: number; plan: string; date: string }): string {
|
||||
return baseTemplate(`
|
||||
<h2 style="color:#1e293b;margin:0 0 16px;">Pago Confirmado</h2>
|
||||
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
|
||||
<p style="color:#475569;line-height:1.6;">Hemos recibido tu pago correctamente.</p>
|
||||
<div style="background-color:#f0fdf4;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #22c55e;">
|
||||
<p style="margin:0;color:#334155;"><strong>Monto:</strong> $${data.amount.toLocaleString('es-MX')} MXN</p>
|
||||
<p style="margin:8px 0 0;color:#334155;"><strong>Plan:</strong> ${data.plan}</p>
|
||||
<p style="margin:8px 0 0;color:#334155;"><strong>Fecha:</strong> ${data.date}</p>
|
||||
</div>
|
||||
<p style="color:#475569;line-height:1.6;">Tu suscripción está activa. Gracias por confiar en Horux360.</p>
|
||||
`);
|
||||
}
|
||||
14
apps/api/src/services/email/templates/payment-failed.ts
Normal file
14
apps/api/src/services/email/templates/payment-failed.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { baseTemplate } from './base.js';
|
||||
|
||||
export function paymentFailedEmail(data: { nombre: string; amount: number; plan: string }): string {
|
||||
return baseTemplate(`
|
||||
<h2 style="color:#1e293b;margin:0 0 16px;">Problema con tu Pago</h2>
|
||||
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
|
||||
<p style="color:#475569;line-height:1.6;">No pudimos procesar tu pago. Por favor verifica tu método de pago.</p>
|
||||
<div style="background-color:#fef2f2;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #ef4444;">
|
||||
<p style="margin:0;color:#334155;"><strong>Monto pendiente:</strong> $${data.amount.toLocaleString('es-MX')} MXN</p>
|
||||
<p style="margin:8px 0 0;color:#334155;"><strong>Plan:</strong> ${data.plan}</p>
|
||||
</div>
|
||||
<p style="color:#475569;line-height:1.6;">Si necesitas ayuda, contacta a soporte respondiendo a este correo.</p>
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { baseTemplate } from './base.js';
|
||||
|
||||
export function subscriptionCancelledEmail(data: { nombre: string; plan: string }): string {
|
||||
return baseTemplate(`
|
||||
<h2 style="color:#1e293b;margin:0 0 16px;">Suscripción Cancelada</h2>
|
||||
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
|
||||
<p style="color:#475569;line-height:1.6;">Tu suscripción al plan <strong>${data.plan}</strong> ha sido cancelada.</p>
|
||||
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
|
||||
<p style="margin:0;color:#334155;">Tu acceso continuará activo hasta el final del período actual de facturación.</p>
|
||||
<p style="margin:8px 0 0;color:#334155;">Después de eso, solo tendrás acceso de lectura a tus datos.</p>
|
||||
</div>
|
||||
<p style="color:#475569;line-height:1.6;">Si deseas reactivar tu suscripción, contacta a soporte.</p>
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { baseTemplate } from './base.js';
|
||||
|
||||
export function subscriptionExpiringEmail(data: { nombre: string; plan: string; expiresAt: string }): string {
|
||||
return baseTemplate(`
|
||||
<h2 style="color:#1e293b;margin:0 0 16px;">Tu Suscripción Vence Pronto</h2>
|
||||
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
|
||||
<p style="color:#475569;line-height:1.6;">Tu suscripción al plan <strong>${data.plan}</strong> vence el <strong>${data.expiresAt}</strong>.</p>
|
||||
<div style="background-color:#fffbeb;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #f59e0b;">
|
||||
<p style="margin:0;color:#334155;">Para evitar interrupciones en el servicio, asegúrate de que tu método de pago esté actualizado.</p>
|
||||
</div>
|
||||
<p style="color:#475569;line-height:1.6;">Si tienes alguna pregunta sobre tu suscripción, contacta a soporte.</p>
|
||||
`);
|
||||
}
|
||||
15
apps/api/src/services/email/templates/welcome.ts
Normal file
15
apps/api/src/services/email/templates/welcome.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { baseTemplate } from './base.js';
|
||||
|
||||
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string {
|
||||
return baseTemplate(`
|
||||
<h2 style="color:#1e293b;margin:0 0 16px;">Bienvenido a Horux360</h2>
|
||||
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
|
||||
<p style="color:#475569;line-height:1.6;">Tu cuenta ha sido creada exitosamente. Aquí tienes tus credenciales de acceso:</p>
|
||||
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
|
||||
<p style="margin:0;color:#334155;"><strong>Email:</strong> ${data.email}</p>
|
||||
<p style="margin:8px 0 0;color:#334155;"><strong>Contraseña temporal:</strong> ${data.tempPassword}</p>
|
||||
</div>
|
||||
<p style="color:#475569;line-height:1.6;">Te recomendamos cambiar tu contraseña después de iniciar sesión.</p>
|
||||
<a href="https://horux360.consultoria-as.com/login" style="display:inline-block;background-color:#2563eb;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:16px;">Iniciar sesión</a>
|
||||
`);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export async function exportCfdisToExcel(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
filters: { tipo?: string; estado?: string; fechaInicio?: string; fechaFin?: string }
|
||||
): Promise<Buffer> {
|
||||
let whereClause = 'WHERE 1=1';
|
||||
@@ -26,15 +26,15 @@ export async function exportCfdisToExcel(
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
|
||||
const cfdis = await prisma.$queryRawUnsafe<any[]>(`
|
||||
const { rows: cfdis } = await pool.query(`
|
||||
SELECT uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||
moneda, metodo_pago, forma_pago, uso_cfdi, estado
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
${whereClause}
|
||||
ORDER BY fecha_emision DESC
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('CFDIs');
|
||||
@@ -63,7 +63,7 @@ export async function exportCfdisToExcel(
|
||||
};
|
||||
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
|
||||
cfdis.forEach(cfdi => {
|
||||
cfdis.forEach((cfdi: any) => {
|
||||
sheet.addRow({
|
||||
...cfdi,
|
||||
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
|
||||
@@ -78,7 +78,7 @@ export async function exportCfdisToExcel(
|
||||
}
|
||||
|
||||
export async function exportReporteToExcel(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
tipo: 'estado-resultados' | 'flujo-efectivo',
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
@@ -87,13 +87,13 @@ export async function exportReporteToExcel(
|
||||
const sheet = workbook.addWorksheet(tipo === 'estado-resultados' ? 'Estado de Resultados' : 'Flujo de Efectivo');
|
||||
|
||||
if (tipo === 'estado-resultados') {
|
||||
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number }]>(`
|
||||
const { rows: [totales] } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
sheet.columns = [
|
||||
{ header: 'Concepto', key: 'concepto', width: 40 },
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Credential } from '@nodecfdi/credentials/node';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { encryptFielCredentials, decryptFielCredentials } from './sat/sat-crypto.service.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { encryptFielCredentials, encrypt, decryptFielCredentials } from './sat/sat-crypto.service.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import type { FielStatus } from '@horux/shared';
|
||||
|
||||
/**
|
||||
@@ -58,13 +62,17 @@ export async function uploadFiel(
|
||||
};
|
||||
}
|
||||
|
||||
// Encriptar credenciales (todas juntas con el mismo IV/tag)
|
||||
// Encriptar credenciales (per-component IV/tag)
|
||||
const {
|
||||
encryptedCer,
|
||||
encryptedKey,
|
||||
encryptedPassword,
|
||||
iv,
|
||||
tag,
|
||||
cerIv,
|
||||
cerTag,
|
||||
keyIv,
|
||||
keyTag,
|
||||
passwordIv,
|
||||
passwordTag,
|
||||
} = encryptFielCredentials(cerData, keyData, password);
|
||||
|
||||
// Guardar o actualizar en BD
|
||||
@@ -76,8 +84,12 @@ export async function uploadFiel(
|
||||
cerData: encryptedCer,
|
||||
keyData: encryptedKey,
|
||||
keyPasswordEncrypted: encryptedPassword,
|
||||
encryptionIv: iv,
|
||||
encryptionTag: tag,
|
||||
cerIv,
|
||||
cerTag,
|
||||
keyIv,
|
||||
keyTag,
|
||||
passwordIv,
|
||||
passwordTag,
|
||||
serialNumber,
|
||||
validFrom,
|
||||
validUntil,
|
||||
@@ -88,8 +100,12 @@ export async function uploadFiel(
|
||||
cerData: encryptedCer,
|
||||
keyData: encryptedKey,
|
||||
keyPasswordEncrypted: encryptedPassword,
|
||||
encryptionIv: iv,
|
||||
encryptionTag: tag,
|
||||
cerIv,
|
||||
cerTag,
|
||||
keyIv,
|
||||
keyTag,
|
||||
passwordIv,
|
||||
passwordTag,
|
||||
serialNumber,
|
||||
validFrom,
|
||||
validUntil,
|
||||
@@ -98,6 +114,49 @@ export async function uploadFiel(
|
||||
},
|
||||
});
|
||||
|
||||
// Save encrypted files to filesystem (dual storage)
|
||||
try {
|
||||
const fielDir = join(env.FIEL_STORAGE_PATH, rfc.toUpperCase());
|
||||
await mkdir(fielDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Re-encrypt for filesystem (independent keys from DB)
|
||||
const fsEncrypted = encryptFielCredentials(cerData, keyData, password);
|
||||
|
||||
await writeFile(join(fielDir, 'certificate.cer.enc'), fsEncrypted.encryptedCer, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'certificate.cer.iv'), fsEncrypted.cerIv, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'certificate.cer.tag'), fsEncrypted.cerTag, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'private_key.key.enc'), fsEncrypted.encryptedKey, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'private_key.key.iv'), fsEncrypted.keyIv, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'private_key.key.tag'), fsEncrypted.keyTag, { mode: 0o600 });
|
||||
|
||||
// Encrypt and store metadata
|
||||
const metadata = JSON.stringify({
|
||||
serial: serialNumber,
|
||||
validFrom: validFrom.toISOString(),
|
||||
validUntil: validUntil.toISOString(),
|
||||
uploadedAt: new Date().toISOString(),
|
||||
rfc: rfc.toUpperCase(),
|
||||
});
|
||||
const metaEncrypted = encrypt(Buffer.from(metadata, 'utf-8'));
|
||||
await writeFile(join(fielDir, 'metadata.json.enc'), metaEncrypted.encrypted, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'metadata.json.iv'), metaEncrypted.iv, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'metadata.json.tag'), metaEncrypted.tag, { mode: 0o600 });
|
||||
} catch (fsError) {
|
||||
console.error('[FIEL] Filesystem storage failed (DB storage OK):', fsError);
|
||||
}
|
||||
|
||||
// Notify admin that client uploaded FIEL
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true, rfc: true },
|
||||
});
|
||||
if (tenant) {
|
||||
emailService.sendFielNotification({
|
||||
clienteNombre: tenant.nombre,
|
||||
clienteRfc: tenant.rfc,
|
||||
}).catch(err => console.error('[EMAIL] FIEL notification failed:', err));
|
||||
}
|
||||
|
||||
const daysUntilExpiration = Math.ceil(
|
||||
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
@@ -198,13 +257,17 @@ export async function getDecryptedFiel(tenantId: string): Promise<{
|
||||
}
|
||||
|
||||
try {
|
||||
// Desencriptar todas las credenciales juntas
|
||||
// Desencriptar credenciales (per-component IV/tag)
|
||||
const { cerData, keyData, password } = decryptFielCredentials(
|
||||
Buffer.from(fiel.cerData),
|
||||
Buffer.from(fiel.keyData),
|
||||
Buffer.from(fiel.keyPasswordEncrypted),
|
||||
Buffer.from(fiel.encryptionIv),
|
||||
Buffer.from(fiel.encryptionTag)
|
||||
Buffer.from(fiel.cerIv),
|
||||
Buffer.from(fiel.cerTag),
|
||||
Buffer.from(fiel.keyIv),
|
||||
Buffer.from(fiel.keyTag),
|
||||
Buffer.from(fiel.passwordIv),
|
||||
Buffer.from(fiel.passwordTag)
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
|
||||
|
||||
export async function getIvaMensual(schema: string, año: number): Promise<IvaMensual[]> {
|
||||
const data = await prisma.$queryRawUnsafe<IvaMensual[]>(`
|
||||
export async function getIvaMensual(pool: Pool, año: number): Promise<IvaMensual[]> {
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT
|
||||
id, año, mes,
|
||||
iva_trasladado as "ivaTrasladado",
|
||||
@@ -10,12 +10,12 @@ export async function getIvaMensual(schema: string, año: number): Promise<IvaMe
|
||||
COALESCE(iva_retenido, 0) as "ivaRetenido",
|
||||
resultado, acumulado, estado,
|
||||
fecha_declaracion as "fechaDeclaracion"
|
||||
FROM "${schema}".iva_mensual
|
||||
FROM iva_mensual
|
||||
WHERE año = $1
|
||||
ORDER BY mes
|
||||
`, año);
|
||||
`, [año]);
|
||||
|
||||
return data.map(row => ({
|
||||
return data.map((row: any) => ({
|
||||
...row,
|
||||
ivaTrasladado: Number(row.ivaTrasladado),
|
||||
ivaAcreditable: Number(row.ivaAcreditable),
|
||||
@@ -25,19 +25,18 @@ export async function getIvaMensual(schema: string, año: number): Promise<IvaMe
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getResumenIva(schema: string, año: number, mes: number): Promise<ResumenIva> {
|
||||
// Get from iva_mensual if exists
|
||||
const existing = await prisma.$queryRawUnsafe<any[]>(`
|
||||
SELECT * FROM "${schema}".iva_mensual WHERE año = $1 AND mes = $2
|
||||
`, año, mes);
|
||||
export async function getResumenIva(pool: Pool, año: number, mes: number): Promise<ResumenIva> {
|
||||
const { rows: existing } = await pool.query(`
|
||||
SELECT * FROM iva_mensual WHERE año = $1 AND mes = $2
|
||||
`, [año, mes]);
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
const record = existing[0];
|
||||
const [acumuladoResult] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
const { rows: [acumuladoResult] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(resultado), 0) as total
|
||||
FROM "${schema}".iva_mensual
|
||||
FROM iva_mensual
|
||||
WHERE año = $1 AND mes <= $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
return {
|
||||
trasladado: Number(record.iva_trasladado || 0),
|
||||
@@ -48,21 +47,16 @@ export async function getResumenIva(schema: string, año: number, mes: number):
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate from CFDIs if no iva_mensual record
|
||||
const [calcResult] = await prisma.$queryRawUnsafe<[{
|
||||
trasladado: number;
|
||||
acreditable: number;
|
||||
retenido: number;
|
||||
}]>(`
|
||||
const { rows: [calcResult] } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable,
|
||||
COALESCE(SUM(iva_retenido), 0) as retenido
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const trasladado = Number(calcResult?.trasladado || 0);
|
||||
const acreditable = Number(calcResult?.acreditable || 0);
|
||||
@@ -78,10 +72,9 @@ export async function getResumenIva(schema: string, año: number, mes: number):
|
||||
};
|
||||
}
|
||||
|
||||
export async function getIsrMensual(schema: string, año: number): Promise<IsrMensual[]> {
|
||||
// Check if isr_mensual table exists
|
||||
export async function getIsrMensual(pool: Pool, año: number): Promise<IsrMensual[]> {
|
||||
try {
|
||||
const data = await prisma.$queryRawUnsafe<IsrMensual[]>(`
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT
|
||||
id, año, mes,
|
||||
ingresos_acumulados as "ingresosAcumulados",
|
||||
@@ -92,12 +85,12 @@ export async function getIsrMensual(schema: string, año: number): Promise<IsrMe
|
||||
isr_a_pagar as "isrAPagar",
|
||||
estado,
|
||||
fecha_declaracion as "fechaDeclaracion"
|
||||
FROM "${schema}".isr_mensual
|
||||
FROM isr_mensual
|
||||
WHERE año = $1
|
||||
ORDER BY mes
|
||||
`, año);
|
||||
`, [año]);
|
||||
|
||||
return data.map(row => ({
|
||||
return data.map((row: any) => ({
|
||||
...row,
|
||||
ingresosAcumulados: Number(row.ingresosAcumulados),
|
||||
deducciones: Number(row.deducciones),
|
||||
@@ -107,43 +100,40 @@ export async function getIsrMensual(schema: string, año: number): Promise<IsrMe
|
||||
isrAPagar: Number(row.isrAPagar),
|
||||
}));
|
||||
} catch {
|
||||
// Table doesn't exist, return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResumenIsr(schema: string, año: number, mes: number): Promise<ResumenIsr> {
|
||||
// Calculate from CFDIs
|
||||
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
export async function getResumenIsr(pool: Pool, año: number, mes: number): Promise<ResumenIsr> {
|
||||
const { rows: [ingresos] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(total), 0) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
const { rows: [egresos] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(total), 0) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const [retenido] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
const { rows: [retenido] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(isr_retenido), 0) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const ingresosAcumulados = Number(ingresos?.total || 0);
|
||||
const deducciones = Number(egresos?.total || 0);
|
||||
const baseGravable = Math.max(0, ingresosAcumulados - deducciones);
|
||||
|
||||
// Simplified ISR calculation (actual calculation would use SAT tables)
|
||||
const isrCausado = baseGravable * 0.30; // 30% simplified rate
|
||||
const isrCausado = baseGravable * 0.30;
|
||||
const isrRetenido = Number(retenido?.total || 0);
|
||||
const isrAPagar = Math.max(0, isrCausado - isrRetenido);
|
||||
|
||||
|
||||
103
apps/api/src/services/payment/mercadopago.service.ts
Normal file
103
apps/api/src/services/payment/mercadopago.service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { MercadoPagoConfig, PreApproval, Payment as MPPayment } from 'mercadopago';
|
||||
import { env } from '../../config/env.js';
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
const config = new MercadoPagoConfig({
|
||||
accessToken: env.MP_ACCESS_TOKEN || '',
|
||||
});
|
||||
|
||||
const preApprovalClient = new PreApproval(config);
|
||||
const paymentClient = new MPPayment(config);
|
||||
|
||||
/**
|
||||
* Creates a recurring subscription (preapproval) in MercadoPago
|
||||
*/
|
||||
export async function createPreapproval(params: {
|
||||
tenantId: string;
|
||||
reason: string;
|
||||
amount: number;
|
||||
payerEmail: string;
|
||||
}) {
|
||||
const response = await preApprovalClient.create({
|
||||
body: {
|
||||
reason: params.reason,
|
||||
external_reference: params.tenantId,
|
||||
payer_email: params.payerEmail,
|
||||
auto_recurring: {
|
||||
frequency: 1,
|
||||
frequency_type: 'months',
|
||||
transaction_amount: params.amount,
|
||||
currency_id: 'MXN',
|
||||
},
|
||||
back_url: `${env.FRONTEND_URL}/configuracion/suscripcion`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
preapprovalId: response.id!,
|
||||
initPoint: response.init_point!,
|
||||
status: response.status!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets subscription (preapproval) status from MercadoPago
|
||||
*/
|
||||
export async function getPreapproval(preapprovalId: string) {
|
||||
const response = await preApprovalClient.get({ id: preapprovalId });
|
||||
return {
|
||||
id: response.id,
|
||||
status: response.status,
|
||||
payerEmail: response.payer_email,
|
||||
nextPaymentDate: response.next_payment_date,
|
||||
autoRecurring: response.auto_recurring,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets payment details from MercadoPago
|
||||
*/
|
||||
export async function getPaymentDetails(paymentId: string) {
|
||||
const response = await paymentClient.get({ id: paymentId });
|
||||
return {
|
||||
id: response.id,
|
||||
status: response.status,
|
||||
statusDetail: response.status_detail,
|
||||
transactionAmount: response.transaction_amount,
|
||||
currencyId: response.currency_id,
|
||||
payerEmail: response.payer?.email,
|
||||
dateApproved: response.date_approved,
|
||||
paymentMethodId: response.payment_method_id,
|
||||
externalReference: response.external_reference,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies MercadoPago webhook signature (HMAC-SHA256)
|
||||
*/
|
||||
export function verifyWebhookSignature(
|
||||
xSignature: string,
|
||||
xRequestId: string,
|
||||
dataId: string
|
||||
): boolean {
|
||||
if (!env.MP_WEBHOOK_SECRET) return true; // Skip in dev
|
||||
|
||||
// Parse x-signature header: "ts=...,v1=..."
|
||||
const parts: Record<string, string> = {};
|
||||
for (const part of xSignature.split(',')) {
|
||||
const [key, value] = part.split('=');
|
||||
parts[key.trim()] = value.trim();
|
||||
}
|
||||
|
||||
const ts = parts['ts'];
|
||||
const v1 = parts['v1'];
|
||||
if (!ts || !v1) return false;
|
||||
|
||||
// Build the manifest string
|
||||
const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`;
|
||||
const hmac = createHmac('sha256', env.MP_WEBHOOK_SECRET)
|
||||
.update(manifest)
|
||||
.digest('hex');
|
||||
|
||||
return hmac === v1;
|
||||
}
|
||||
232
apps/api/src/services/payment/subscription.service.ts
Normal file
232
apps/api/src/services/payment/subscription.service.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { prisma } from '../../config/database.js';
|
||||
import * as mpService from './mercadopago.service.js';
|
||||
import { emailService } from '../email/email.service.js';
|
||||
|
||||
// Simple in-memory cache with TTL
|
||||
const subscriptionCache = new Map<string, { data: any; expires: number }>();
|
||||
|
||||
export function invalidateSubscriptionCache(tenantId: string) {
|
||||
subscriptionCache.delete(`sub:${tenantId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription record in DB and a MercadoPago preapproval
|
||||
*/
|
||||
export async function createSubscription(params: {
|
||||
tenantId: string;
|
||||
plan: string;
|
||||
amount: number;
|
||||
payerEmail: string;
|
||||
}) {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: params.tenantId },
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
|
||||
// Create MercadoPago preapproval
|
||||
const mp = await mpService.createPreapproval({
|
||||
tenantId: params.tenantId,
|
||||
reason: `Horux360 - Plan ${params.plan} - ${tenant.nombre}`,
|
||||
amount: params.amount,
|
||||
payerEmail: params.payerEmail,
|
||||
});
|
||||
|
||||
// Create DB record
|
||||
const subscription = await prisma.subscription.create({
|
||||
data: {
|
||||
tenantId: params.tenantId,
|
||||
plan: params.plan as any,
|
||||
status: mp.status || 'pending',
|
||||
amount: params.amount,
|
||||
frequency: 'monthly',
|
||||
mpPreapprovalId: mp.preapprovalId,
|
||||
},
|
||||
});
|
||||
|
||||
invalidateSubscriptionCache(params.tenantId);
|
||||
|
||||
return {
|
||||
subscription,
|
||||
paymentUrl: mp.initPoint,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets active subscription for a tenant (cached 5 min)
|
||||
*/
|
||||
export async function getActiveSubscription(tenantId: string) {
|
||||
const cached = subscriptionCache.get(`sub:${tenantId}`);
|
||||
if (cached && cached.expires > Date.now()) return cached.data;
|
||||
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
subscriptionCache.set(`sub:${tenantId}`, {
|
||||
data: subscription,
|
||||
expires: Date.now() + 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates subscription status from webhook notification
|
||||
*/
|
||||
export async function updateSubscriptionStatus(mpPreapprovalId: string, status: string) {
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { mpPreapprovalId },
|
||||
});
|
||||
if (!subscription) return null;
|
||||
|
||||
const updated = await prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { status },
|
||||
});
|
||||
|
||||
invalidateSubscriptionCache(subscription.tenantId);
|
||||
|
||||
// Handle cancellation
|
||||
if (status === 'cancelled') {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: subscription.tenantId },
|
||||
include: { users: { where: { role: 'admin' }, take: 1 } },
|
||||
});
|
||||
if (tenant && tenant.users[0]) {
|
||||
emailService.sendSubscriptionCancelled(tenant.users[0].email, {
|
||||
nombre: tenant.nombre,
|
||||
plan: subscription.plan,
|
||||
}).catch(err => console.error('[EMAIL] Subscription cancelled notification failed:', err));
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a payment from MercadoPago webhook
|
||||
*/
|
||||
export async function recordPayment(params: {
|
||||
tenantId: string;
|
||||
subscriptionId: string;
|
||||
mpPaymentId: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
paymentMethod: string;
|
||||
}) {
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
tenantId: params.tenantId,
|
||||
subscriptionId: params.subscriptionId,
|
||||
mpPaymentId: params.mpPaymentId,
|
||||
amount: params.amount,
|
||||
status: params.status,
|
||||
paymentMethod: params.paymentMethod,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email notifications based on payment status
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: params.tenantId },
|
||||
include: { users: { where: { role: 'admin' }, take: 1 } },
|
||||
});
|
||||
|
||||
if (tenant && tenant.users[0]) {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { id: params.subscriptionId },
|
||||
});
|
||||
|
||||
if (params.status === 'approved') {
|
||||
emailService.sendPaymentConfirmed(tenant.users[0].email, {
|
||||
nombre: tenant.nombre,
|
||||
amount: params.amount,
|
||||
plan: subscription?.plan || 'N/A',
|
||||
date: new Date().toLocaleDateString('es-MX'),
|
||||
}).catch(err => console.error('[EMAIL] Payment confirmed notification failed:', err));
|
||||
} else if (params.status === 'rejected') {
|
||||
emailService.sendPaymentFailed(tenant.users[0].email, {
|
||||
nombre: tenant.nombre,
|
||||
amount: params.amount,
|
||||
plan: subscription?.plan || 'N/A',
|
||||
}).catch(err => console.error('[EMAIL] Payment failed notification failed:', err));
|
||||
}
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually marks a subscription as paid (for bank transfers)
|
||||
*/
|
||||
export async function markAsPaidManually(tenantId: string, amount: number) {
|
||||
const subscription = await getActiveSubscription(tenantId);
|
||||
if (!subscription) throw new Error('No hay suscripción activa');
|
||||
|
||||
// Update subscription status
|
||||
await prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { status: 'authorized' },
|
||||
});
|
||||
|
||||
// Record the manual payment
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
tenantId,
|
||||
subscriptionId: subscription.id,
|
||||
mpPaymentId: `manual-${Date.now()}`,
|
||||
amount,
|
||||
status: 'approved',
|
||||
paymentMethod: 'bank_transfer',
|
||||
},
|
||||
});
|
||||
|
||||
invalidateSubscriptionCache(tenantId);
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a payment link for a tenant
|
||||
*/
|
||||
export async function generatePaymentLink(tenantId: string) {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
include: { users: { where: { role: 'admin' }, take: 1 } },
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
if (!tenant.users[0]) throw new Error('No admin user found');
|
||||
|
||||
const subscription = await getActiveSubscription(tenantId);
|
||||
const plan = subscription?.plan || tenant.plan;
|
||||
const amount = subscription?.amount || 0;
|
||||
|
||||
if (!amount) throw new Error('No se encontró monto de suscripción');
|
||||
|
||||
const mp = await mpService.createPreapproval({
|
||||
tenantId,
|
||||
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
|
||||
amount,
|
||||
payerEmail: tenant.users[0].email,
|
||||
});
|
||||
|
||||
// Update subscription with new MP preapproval ID
|
||||
if (subscription) {
|
||||
await prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { mpPreapprovalId: mp.preapprovalId },
|
||||
});
|
||||
}
|
||||
|
||||
return { paymentUrl: mp.initPoint };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets payment history for a tenant
|
||||
*/
|
||||
export async function getPaymentHistory(tenantId: string) {
|
||||
return prisma.payment.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
|
||||
|
||||
// Helper to convert Prisma Decimal/BigInt to number
|
||||
function toNumber(value: unknown): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
if (typeof value === 'number') return value;
|
||||
@@ -14,37 +13,37 @@ function toNumber(value: unknown): number {
|
||||
}
|
||||
|
||||
export async function getEstadoResultados(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
): Promise<EstadoResultados> {
|
||||
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
|
||||
const { rows: ingresos } = await pool.query(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY total DESC LIMIT 10
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
|
||||
const { rows: egresos } = await pool.query(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY total DESC LIMIT 10
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const totalesResult = await prisma.$queryRawUnsafe<{ ingresos: unknown; egresos: unknown; iva: unknown }[]>(`
|
||||
const { rows: totalesResult } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const totales = totalesResult[0];
|
||||
const totalIngresos = toNumber(totales?.ingresos);
|
||||
@@ -54,8 +53,8 @@ export async function getEstadoResultados(
|
||||
|
||||
return {
|
||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: toNumber(i.total) })),
|
||||
egresos: egresos.map(e => ({ concepto: e.nombre, monto: toNumber(e.total) })),
|
||||
ingresos: ingresos.map((i: any) => ({ concepto: i.nombre, monto: toNumber(i.total) })),
|
||||
egresos: egresos.map((e: any) => ({ concepto: e.nombre, monto: toNumber(e.total) })),
|
||||
totalIngresos,
|
||||
totalEgresos,
|
||||
utilidadBruta,
|
||||
@@ -65,36 +64,36 @@ export async function getEstadoResultados(
|
||||
}
|
||||
|
||||
export async function getFlujoEfectivo(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
): Promise<FlujoEfectivo> {
|
||||
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
|
||||
const { rows: entradas } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||
ORDER BY mes
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
|
||||
const { rows: salidas } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||
ORDER BY mes
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const totalEntradas = entradas.reduce((sum, e) => sum + toNumber(e.total), 0);
|
||||
const totalSalidas = salidas.reduce((sum, s) => sum + toNumber(s.total), 0);
|
||||
const totalEntradas = entradas.reduce((sum: number, e: any) => sum + toNumber(e.total), 0);
|
||||
const totalSalidas = salidas.reduce((sum: number, s: any) => sum + toNumber(s.total), 0);
|
||||
|
||||
return {
|
||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||
saldoInicial: 0,
|
||||
entradas: entradas.map(e => ({ concepto: e.mes, monto: toNumber(e.total) })),
|
||||
salidas: salidas.map(s => ({ concepto: s.mes, monto: toNumber(s.total) })),
|
||||
entradas: entradas.map((e: any) => ({ concepto: e.mes, monto: toNumber(e.total) })),
|
||||
salidas: salidas.map((s: any) => ({ concepto: s.mes, monto: toNumber(s.total) })),
|
||||
totalEntradas,
|
||||
totalSalidas,
|
||||
flujoNeto: totalEntradas - totalSalidas,
|
||||
@@ -103,36 +102,36 @@ export async function getFlujoEfectivo(
|
||||
}
|
||||
|
||||
export async function getComparativo(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
año: number
|
||||
): Promise<ComparativoPeriodos> {
|
||||
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
|
||||
const { rows: actual } = await pool.query(`
|
||||
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, año);
|
||||
`, [año]);
|
||||
|
||||
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
|
||||
const { rows: anterior } = await pool.query(`
|
||||
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, año - 1);
|
||||
`, [año - 1]);
|
||||
|
||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
const ingresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.ingresos));
|
||||
const egresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.egresos));
|
||||
const ingresos = meses.map((_, i) => toNumber(actual.find((a: any) => a.mes === i + 1)?.ingresos));
|
||||
const egresos = meses.map((_, i) => toNumber(actual.find((a: any) => a.mes === i + 1)?.egresos));
|
||||
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
|
||||
|
||||
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
|
||||
const totalAnteriorIng = anterior.reduce((a, b) => a + toNumber(b.ingresos), 0);
|
||||
const totalAnteriorIng = anterior.reduce((a: number, b: any) => a + toNumber(b.ingresos), 0);
|
||||
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
|
||||
const totalAnteriorEgr = anterior.reduce((a, b) => a + toNumber(b.egresos), 0);
|
||||
const totalAnteriorEgr = anterior.reduce((a: number, b: any) => a + toNumber(b.egresos), 0);
|
||||
|
||||
return {
|
||||
periodos: meses,
|
||||
@@ -146,25 +145,25 @@ export async function getComparativo(
|
||||
}
|
||||
|
||||
export async function getConcentradoRfc(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
tipo: 'cliente' | 'proveedor'
|
||||
): Promise<ConcentradoRfc[]> {
|
||||
if (tipo === 'cliente') {
|
||||
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
||||
'cliente' as tipo,
|
||||
SUM(total) as "totalFacturado",
|
||||
SUM(iva) as "totalIva",
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, fechaInicio, fechaFin);
|
||||
return data.map(d => ({
|
||||
`, [fechaInicio, fechaFin]);
|
||||
return data.map((d: any) => ({
|
||||
rfc: d.rfc,
|
||||
nombre: d.nombre,
|
||||
tipo: 'cliente' as const,
|
||||
@@ -173,19 +172,19 @@ export async function getConcentradoRfc(
|
||||
cantidadCfdis: d.cantidadCfdis
|
||||
}));
|
||||
} else {
|
||||
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
||||
'proveedor' as tipo,
|
||||
SUM(total) as "totalFacturado",
|
||||
SUM(iva) as "totalIva",
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, fechaInicio, fechaFin);
|
||||
return data.map(d => ({
|
||||
`, [fechaInicio, fechaFin]);
|
||||
return data.map((d: any) => ({
|
||||
rfc: d.rfc,
|
||||
nombre: d.nombre,
|
||||
tipo: 'proveedor' as const,
|
||||
|
||||
@@ -6,10 +6,10 @@ const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Deriva una clave de 256 bits del JWT_SECRET
|
||||
* Deriva una clave de 256 bits del FIEL_ENCRYPTION_KEY
|
||||
*/
|
||||
function deriveKey(): Buffer {
|
||||
return createHash('sha256').update(env.JWT_SECRET).digest();
|
||||
return createHash('sha256').update(env.FIEL_ENCRYPTION_KEY).digest();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +52,7 @@ export function decryptToString(encrypted: Buffer, iv: Buffer, tag: Buffer): str
|
||||
}
|
||||
|
||||
/**
|
||||
* Encripta credenciales FIEL (cer, key, password)
|
||||
* Encripta credenciales FIEL con IV/tag independiente por componente
|
||||
*/
|
||||
export function encryptFielCredentials(
|
||||
cerData: Buffer,
|
||||
@@ -62,61 +62,51 @@ export function encryptFielCredentials(
|
||||
encryptedCer: Buffer;
|
||||
encryptedKey: Buffer;
|
||||
encryptedPassword: Buffer;
|
||||
iv: Buffer;
|
||||
tag: Buffer;
|
||||
cerIv: Buffer;
|
||||
cerTag: Buffer;
|
||||
keyIv: Buffer;
|
||||
keyTag: Buffer;
|
||||
passwordIv: Buffer;
|
||||
passwordTag: Buffer;
|
||||
} {
|
||||
// Usamos el mismo IV y tag para simplificar, concatenando los datos
|
||||
const combined = Buffer.concat([
|
||||
Buffer.from(cerData.length.toString().padStart(10, '0')),
|
||||
cerData,
|
||||
Buffer.from(keyData.length.toString().padStart(10, '0')),
|
||||
keyData,
|
||||
Buffer.from(password, 'utf-8'),
|
||||
]);
|
||||
|
||||
const { encrypted, iv, tag } = encrypt(combined);
|
||||
|
||||
// Extraemos las partes encriptadas
|
||||
const cerLength = cerData.length;
|
||||
const keyLength = keyData.length;
|
||||
const passwordLength = Buffer.from(password, 'utf-8').length;
|
||||
const cer = encrypt(cerData);
|
||||
const key = encrypt(keyData);
|
||||
const pwd = encrypt(Buffer.from(password, 'utf-8'));
|
||||
|
||||
return {
|
||||
encryptedCer: encrypted.subarray(0, 10 + cerLength),
|
||||
encryptedKey: encrypted.subarray(10 + cerLength, 20 + cerLength + keyLength),
|
||||
encryptedPassword: encrypted.subarray(20 + cerLength + keyLength),
|
||||
iv,
|
||||
tag,
|
||||
encryptedCer: cer.encrypted,
|
||||
encryptedKey: key.encrypted,
|
||||
encryptedPassword: pwd.encrypted,
|
||||
cerIv: cer.iv,
|
||||
cerTag: cer.tag,
|
||||
keyIv: key.iv,
|
||||
keyTag: key.tag,
|
||||
passwordIv: pwd.iv,
|
||||
passwordTag: pwd.tag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Desencripta credenciales FIEL
|
||||
* Desencripta credenciales FIEL (per-component IV/tag)
|
||||
*/
|
||||
export function decryptFielCredentials(
|
||||
encryptedCer: Buffer,
|
||||
encryptedKey: Buffer,
|
||||
encryptedPassword: Buffer,
|
||||
iv: Buffer,
|
||||
tag: Buffer
|
||||
cerIv: Buffer,
|
||||
cerTag: Buffer,
|
||||
keyIv: Buffer,
|
||||
keyTag: Buffer,
|
||||
passwordIv: Buffer,
|
||||
passwordTag: Buffer
|
||||
): {
|
||||
cerData: Buffer;
|
||||
keyData: Buffer;
|
||||
password: string;
|
||||
} {
|
||||
const combined = Buffer.concat([encryptedCer, encryptedKey, encryptedPassword]);
|
||||
const decrypted = decrypt(combined, iv, tag);
|
||||
|
||||
// Parseamos las partes
|
||||
const cerLengthStr = decrypted.subarray(0, 10).toString();
|
||||
const cerLength = parseInt(cerLengthStr, 10);
|
||||
const cerData = decrypted.subarray(10, 10 + cerLength);
|
||||
|
||||
const keyLengthStr = decrypted.subarray(10 + cerLength, 20 + cerLength).toString();
|
||||
const keyLength = parseInt(keyLengthStr, 10);
|
||||
const keyData = decrypted.subarray(20 + cerLength, 20 + cerLength + keyLength);
|
||||
|
||||
const password = decrypted.subarray(20 + cerLength + keyLength).toString('utf-8');
|
||||
const cerData = decrypt(encryptedCer, cerIv, cerTag);
|
||||
const keyData = decrypt(encryptedKey, keyIv, keyTag);
|
||||
const password = decrypt(encryptedPassword, passwordIv, passwordTag).toString('utf-8');
|
||||
|
||||
return { cerData, keyData, password };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma } from '../../config/database.js';
|
||||
import { prisma, tenantDb } from '../../config/database.js';
|
||||
import { getDecryptedFiel } from '../fiel.service.js';
|
||||
import {
|
||||
createSatService,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { processPackage, type CfdiParsed } from './sat-parser.service.js';
|
||||
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
|
||||
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
const POLL_INTERVAL_MS = 30000; // 30 segundos
|
||||
const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo
|
||||
@@ -20,7 +21,7 @@ interface SyncContext {
|
||||
service: Service;
|
||||
rfc: string;
|
||||
tenantId: string;
|
||||
schemaName: string;
|
||||
pool: Pool;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +55,7 @@ async function updateJobProgress(
|
||||
* Guarda los CFDIs en la base de datos del tenant
|
||||
*/
|
||||
async function saveCfdis(
|
||||
schemaName: string,
|
||||
pool: Pool,
|
||||
cfdis: CfdiParsed[],
|
||||
jobId: string
|
||||
): Promise<{ inserted: number; updated: number }> {
|
||||
@@ -63,16 +64,14 @@ async function saveCfdis(
|
||||
|
||||
for (const cfdi of cfdis) {
|
||||
try {
|
||||
// Usar raw query para el esquema del tenant
|
||||
const existing = await prisma.$queryRawUnsafe<{ id: string }[]>(
|
||||
`SELECT id FROM "${schemaName}".cfdis WHERE uuid_fiscal = $1`,
|
||||
cfdi.uuidFiscal
|
||||
const { rows: existing } = await pool.query(
|
||||
`SELECT id FROM cfdis WHERE uuid_fiscal = $1`,
|
||||
[cfdi.uuidFiscal]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Actualizar CFDI existente
|
||||
await prisma.$executeRawUnsafe(
|
||||
`UPDATE "${schemaName}".cfdis SET
|
||||
await pool.query(
|
||||
`UPDATE cfdis SET
|
||||
tipo = $2,
|
||||
serie = $3,
|
||||
folio = $4,
|
||||
@@ -99,6 +98,7 @@ async function saveCfdis(
|
||||
sat_sync_job_id = $24::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE uuid_fiscal = $1`,
|
||||
[
|
||||
cfdi.uuidFiscal,
|
||||
cfdi.tipo,
|
||||
cfdi.serie,
|
||||
@@ -123,12 +123,12 @@ async function saveCfdis(
|
||||
cfdi.estado,
|
||||
cfdi.xmlOriginal,
|
||||
jobId
|
||||
]
|
||||
);
|
||||
updated++;
|
||||
} else {
|
||||
// Insertar nuevo CFDI
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO "${schemaName}".cfdis (
|
||||
await pool.query(
|
||||
`INSERT INTO cfdis (
|
||||
id, uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||
@@ -139,6 +139,7 @@ async function saveCfdis(
|
||||
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
|
||||
$23, 'sat', $24::uuid, NOW(), NOW()
|
||||
)`,
|
||||
[
|
||||
cfdi.uuidFiscal,
|
||||
cfdi.tipo,
|
||||
cfdi.serie,
|
||||
@@ -163,6 +164,7 @@ async function saveCfdis(
|
||||
cfdi.estado,
|
||||
cfdi.xmlOriginal,
|
||||
jobId
|
||||
]
|
||||
);
|
||||
inserted++;
|
||||
}
|
||||
@@ -186,11 +188,9 @@ async function processDateRange(
|
||||
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
||||
console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
|
||||
|
||||
// 1. Solicitar descarga
|
||||
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi);
|
||||
|
||||
if (!queryResult.success) {
|
||||
// Código 5004 = No hay CFDIs en el rango
|
||||
if (queryResult.statusCode === '5004') {
|
||||
console.log('[SAT] No se encontraron CFDIs en el rango');
|
||||
return { found: 0, downloaded: 0, inserted: 0, updated: 0 };
|
||||
@@ -203,7 +203,6 @@ async function processDateRange(
|
||||
|
||||
await updateJobProgress(jobId, { satRequestId: requestId });
|
||||
|
||||
// 2. Esperar y verificar solicitud
|
||||
let verifyResult;
|
||||
let attempts = 0;
|
||||
|
||||
@@ -227,7 +226,6 @@ async function processDateRange(
|
||||
throw new Error('Timeout esperando respuesta del SAT');
|
||||
}
|
||||
|
||||
// 3. Descargar paquetes
|
||||
const packageIds = verifyResult.packageIds;
|
||||
await updateJobProgress(jobId, {
|
||||
satPackageIds: packageIds,
|
||||
@@ -249,17 +247,15 @@ async function processDateRange(
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Procesar paquete (el contenido viene en base64)
|
||||
const cfdis = processPackage(downloadResult.packageContent);
|
||||
totalDownloaded += cfdis.length;
|
||||
|
||||
console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`);
|
||||
|
||||
const { inserted, updated } = await saveCfdis(ctx.schemaName, cfdis, jobId);
|
||||
const { inserted, updated } = await saveCfdis(ctx.pool, cfdis, jobId);
|
||||
totalInserted += inserted;
|
||||
totalUpdated += updated;
|
||||
|
||||
// Actualizar progreso
|
||||
const progress = Math.round(((i + 1) / packageIds.length) * 100);
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
@@ -287,7 +283,6 @@ async function processInitialSync(
|
||||
customDateTo?: Date
|
||||
): Promise<void> {
|
||||
const ahora = new Date();
|
||||
// Usar fechas personalizadas si se proporcionan, sino calcular desde YEARS_TO_SYNC
|
||||
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
|
||||
const fechaFin = customDateTo || ahora;
|
||||
|
||||
@@ -296,14 +291,12 @@ async function processInitialSync(
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
|
||||
// Procesar por meses para evitar límites del SAT
|
||||
let currentDate = new Date(inicioHistorico);
|
||||
|
||||
while (currentDate < fechaFin) {
|
||||
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
|
||||
const rangeEnd = monthEnd > fechaFin ? fechaFin : monthEnd;
|
||||
|
||||
// Procesar emitidos
|
||||
try {
|
||||
const emitidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'emitidos');
|
||||
totalFound += emitidos.found;
|
||||
@@ -314,7 +307,6 @@ async function processInitialSync(
|
||||
console.error(`[SAT] Error procesando emitidos ${currentDate.toISOString()}:`, error.message);
|
||||
}
|
||||
|
||||
// Procesar recibidos
|
||||
try {
|
||||
const recibidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'recibidos');
|
||||
totalFound += recibidos.found;
|
||||
@@ -325,10 +317,8 @@ async function processInitialSync(
|
||||
console.error(`[SAT] Error procesando recibidos ${currentDate.toISOString()}:`, error.message);
|
||||
}
|
||||
|
||||
// Siguiente mes
|
||||
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
|
||||
|
||||
// Pequeña pausa entre meses para no saturar el SAT
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
|
||||
@@ -352,7 +342,6 @@ async function processDailySync(ctx: SyncContext, jobId: string): Promise<void>
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
|
||||
// Procesar emitidos del mes
|
||||
try {
|
||||
const emitidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'emitidos');
|
||||
totalFound += emitidos.found;
|
||||
@@ -363,7 +352,6 @@ async function processDailySync(ctx: SyncContext, jobId: string): Promise<void>
|
||||
console.error('[SAT] Error procesando emitidos:', error.message);
|
||||
}
|
||||
|
||||
// Procesar recibidos del mes
|
||||
try {
|
||||
const recibidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'recibidos');
|
||||
totalFound += recibidos.found;
|
||||
@@ -391,7 +379,6 @@ export async function startSync(
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<string> {
|
||||
// Obtener credenciales FIEL
|
||||
const decryptedFiel = await getDecryptedFiel(tenantId);
|
||||
if (!decryptedFiel) {
|
||||
throw new Error('No hay FIEL configurada o está vencida');
|
||||
@@ -403,20 +390,17 @@ export async function startSync(
|
||||
password: decryptedFiel.password,
|
||||
};
|
||||
|
||||
// Crear servicio SAT
|
||||
const service = createSatService(fielData);
|
||||
|
||||
// Obtener datos del tenant
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { schemaName: true },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error('Tenant no encontrado');
|
||||
}
|
||||
|
||||
// Verificar que no haya sync activo
|
||||
const activeSync = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
@@ -428,7 +412,6 @@ export async function startSync(
|
||||
throw new Error('Ya hay una sincronización en curso');
|
||||
}
|
||||
|
||||
// Crear job
|
||||
const now = new Date();
|
||||
const job = await prisma.satSyncJob.create({
|
||||
data: {
|
||||
@@ -446,7 +429,7 @@ export async function startSync(
|
||||
service,
|
||||
rfc: decryptedFiel.rfc,
|
||||
tenantId,
|
||||
schemaName: tenant.schemaName,
|
||||
pool: tenantDb.getPool(tenantId, tenant.databaseName),
|
||||
};
|
||||
|
||||
// Ejecutar sincronización en background
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { PLANS } from '@horux/shared';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import { randomBytes } from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function getAllTenants() {
|
||||
return prisma.tenant.findMany({
|
||||
@@ -8,7 +12,7 @@ export async function getAllTenants() {
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
plan: true,
|
||||
schemaName: true,
|
||||
databaseName: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: { users: true }
|
||||
@@ -26,7 +30,7 @@ export async function getTenantById(id: string) {
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
plan: true,
|
||||
schemaName: true,
|
||||
databaseName: true,
|
||||
cfdiLimit: true,
|
||||
usersLimit: true,
|
||||
createdAt: true,
|
||||
@@ -40,104 +44,61 @@ export async function createTenant(data: {
|
||||
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
|
||||
cfdiLimit?: number;
|
||||
usersLimit?: number;
|
||||
adminEmail: string;
|
||||
adminNombre: string;
|
||||
amount: number;
|
||||
}) {
|
||||
const schemaName = `tenant_${data.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
const plan = data.plan || 'starter';
|
||||
const planConfig = PLANS[plan];
|
||||
|
||||
// Create tenant record
|
||||
// 1. Provision a dedicated database for this tenant
|
||||
const databaseName = await tenantDb.provisionDatabase(data.rfc);
|
||||
|
||||
// 2. Create tenant record
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: data.nombre,
|
||||
rfc: data.rfc.toUpperCase(),
|
||||
plan: data.plan || 'starter',
|
||||
schemaName,
|
||||
cfdiLimit: data.cfdiLimit || 500,
|
||||
usersLimit: data.usersLimit || 3,
|
||||
plan,
|
||||
databaseName,
|
||||
cfdiLimit: data.cfdiLimit || planConfig.cfdiLimit,
|
||||
usersLimit: data.usersLimit || planConfig.usersLimit,
|
||||
}
|
||||
});
|
||||
|
||||
// Create schema and tables for the tenant
|
||||
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
|
||||
// 3. Create admin user with temp password
|
||||
const tempPassword = randomBytes(4).toString('hex'); // 8-char random
|
||||
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||
|
||||
// Create CFDIs table
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
|
||||
tipo VARCHAR(20) NOT NULL,
|
||||
serie VARCHAR(25),
|
||||
folio VARCHAR(40),
|
||||
fecha_emision TIMESTAMP NOT NULL,
|
||||
fecha_timbrado TIMESTAMP NOT NULL,
|
||||
rfc_emisor VARCHAR(13) NOT NULL,
|
||||
nombre_emisor VARCHAR(300) NOT NULL,
|
||||
rfc_receptor VARCHAR(13) NOT NULL,
|
||||
nombre_receptor VARCHAR(300) NOT NULL,
|
||||
subtotal DECIMAL(18,2) NOT NULL,
|
||||
descuento DECIMAL(18,2) DEFAULT 0,
|
||||
iva DECIMAL(18,2) DEFAULT 0,
|
||||
isr_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
total DECIMAL(18,2) NOT NULL,
|
||||
moneda VARCHAR(3) DEFAULT 'MXN',
|
||||
tipo_cambio DECIMAL(10,4) DEFAULT 1,
|
||||
metodo_pago VARCHAR(3),
|
||||
forma_pago VARCHAR(2),
|
||||
uso_cfdi VARCHAR(4),
|
||||
estado VARCHAR(20) DEFAULT 'vigente',
|
||||
xml_url TEXT,
|
||||
pdf_url TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
email: data.adminEmail,
|
||||
passwordHash: hashedPassword,
|
||||
nombre: data.adminNombre,
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
// Create IVA monthly table
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
año INT NOT NULL,
|
||||
mes INT NOT NULL,
|
||||
iva_trasladado DECIMAL(18,2) NOT NULL,
|
||||
iva_acreditable DECIMAL(18,2) NOT NULL,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
resultado DECIMAL(18,2) NOT NULL,
|
||||
acumulado DECIMAL(18,2) NOT NULL,
|
||||
estado VARCHAR(20) DEFAULT 'pendiente',
|
||||
fecha_declaracion TIMESTAMP,
|
||||
UNIQUE(año, mes)
|
||||
)
|
||||
`);
|
||||
// 4. Create initial subscription
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
plan,
|
||||
status: 'pending',
|
||||
amount: data.amount,
|
||||
frequency: 'monthly',
|
||||
},
|
||||
});
|
||||
|
||||
// Create alerts table
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
mensaje TEXT NOT NULL,
|
||||
prioridad VARCHAR(20) DEFAULT 'media',
|
||||
fecha_vencimiento TIMESTAMP,
|
||||
leida BOOLEAN DEFAULT FALSE,
|
||||
resuelta BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
// 5. Send welcome email (non-blocking)
|
||||
emailService.sendWelcome(data.adminEmail, {
|
||||
nombre: data.adminNombre,
|
||||
email: data.adminEmail,
|
||||
tempPassword,
|
||||
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
|
||||
|
||||
// Create calendario_fiscal table
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
descripcion TEXT,
|
||||
tipo VARCHAR(20) NOT NULL,
|
||||
fecha_limite TIMESTAMP NOT NULL,
|
||||
recurrencia VARCHAR(20) DEFAULT 'mensual',
|
||||
completado BOOLEAN DEFAULT FALSE,
|
||||
notas TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
return tenant;
|
||||
return { tenant, user, tempPassword };
|
||||
}
|
||||
|
||||
export async function updateTenant(id: string, data: {
|
||||
@@ -163,7 +124,7 @@ export async function updateTenant(id: string, data: {
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
plan: true,
|
||||
schemaName: true,
|
||||
databaseName: true,
|
||||
cfdiLimit: true,
|
||||
usersLimit: true,
|
||||
active: true,
|
||||
@@ -173,9 +134,20 @@ export async function updateTenant(id: string, data: {
|
||||
}
|
||||
|
||||
export async function deleteTenant(id: string) {
|
||||
// Soft delete - just mark as inactive
|
||||
return prisma.tenant.update({
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
|
||||
// Soft-delete the tenant record
|
||||
await prisma.tenant.update({
|
||||
where: { id },
|
||||
data: { active: false }
|
||||
});
|
||||
|
||||
// Soft-delete the database (rename with _deleted_ suffix)
|
||||
if (tenant) {
|
||||
await tenantDb.deprovisionDatabase(tenant.databaseName);
|
||||
tenantDb.invalidatePool(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export async function createTenantSchema(schemaName: string): Promise<void> {
|
||||
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
|
||||
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
|
||||
tipo VARCHAR(20) NOT NULL,
|
||||
serie VARCHAR(25),
|
||||
folio VARCHAR(40),
|
||||
fecha_emision TIMESTAMP NOT NULL,
|
||||
fecha_timbrado TIMESTAMP NOT NULL,
|
||||
rfc_emisor VARCHAR(13) NOT NULL,
|
||||
nombre_emisor VARCHAR(300) NOT NULL,
|
||||
rfc_receptor VARCHAR(13) NOT NULL,
|
||||
nombre_receptor VARCHAR(300) NOT NULL,
|
||||
subtotal DECIMAL(18,2) NOT NULL,
|
||||
descuento DECIMAL(18,2) DEFAULT 0,
|
||||
iva DECIMAL(18,2) DEFAULT 0,
|
||||
isr_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
total DECIMAL(18,2) NOT NULL,
|
||||
moneda VARCHAR(3) DEFAULT 'MXN',
|
||||
tipo_cambio DECIMAL(10,4) DEFAULT 1,
|
||||
metodo_pago VARCHAR(3),
|
||||
forma_pago VARCHAR(2),
|
||||
uso_cfdi VARCHAR(4),
|
||||
estado VARCHAR(20) DEFAULT 'vigente',
|
||||
xml_url TEXT,
|
||||
pdf_url TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
año INTEGER NOT NULL,
|
||||
mes INTEGER NOT NULL,
|
||||
iva_trasladado DECIMAL(18,2) NOT NULL,
|
||||
iva_acreditable DECIMAL(18,2) NOT NULL,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
resultado DECIMAL(18,2) NOT NULL,
|
||||
acumulado DECIMAL(18,2) NOT NULL,
|
||||
estado VARCHAR(20) DEFAULT 'pendiente',
|
||||
fecha_declaracion TIMESTAMP,
|
||||
UNIQUE(año, mes)
|
||||
)
|
||||
`);
|
||||
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."isr_mensual" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
año INTEGER NOT NULL,
|
||||
mes INTEGER NOT NULL,
|
||||
ingresos_acumulados DECIMAL(18,2) NOT NULL,
|
||||
deducciones DECIMAL(18,2) NOT NULL,
|
||||
base_gravable DECIMAL(18,2) NOT NULL,
|
||||
isr_causado DECIMAL(18,2) NOT NULL,
|
||||
isr_retenido DECIMAL(18,2) NOT NULL,
|
||||
isr_a_pagar DECIMAL(18,2) NOT NULL,
|
||||
estado VARCHAR(20) DEFAULT 'pendiente',
|
||||
fecha_declaracion TIMESTAMP,
|
||||
UNIQUE(año, mes)
|
||||
)
|
||||
`);
|
||||
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
mensaje TEXT NOT NULL,
|
||||
prioridad VARCHAR(20) DEFAULT 'media',
|
||||
fecha_vencimiento TIMESTAMP,
|
||||
leida BOOLEAN DEFAULT FALSE,
|
||||
resuelta BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
descripcion TEXT,
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
fecha_limite TIMESTAMP NOT NULL,
|
||||
recurrencia VARCHAR(20) DEFAULT 'unica',
|
||||
completado BOOLEAN DEFAULT FALSE,
|
||||
notas TEXT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export async function setTenantSchema(schemaName: string): Promise<void> {
|
||||
await prisma.$executeRawUnsafe(`SET search_path TO "${schemaName}"`);
|
||||
}
|
||||
|
||||
export async function deleteTenantSchema(schemaName: string): Promise<void> {
|
||||
await prisma.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
|
||||
}
|
||||
127
apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx
Normal file
127
apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSubscription, usePaymentHistory } from '@/lib/hooks/use-subscription';
|
||||
import { CreditCard, Calendar, CheckCircle, AlertCircle, Clock, XCircle } from 'lucide-react';
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string; icon: typeof CheckCircle }> = {
|
||||
authorized: { label: 'Activa', color: 'text-green-600 bg-green-50', icon: CheckCircle },
|
||||
pending: { label: 'Pendiente', color: 'text-yellow-600 bg-yellow-50', icon: Clock },
|
||||
paused: { label: 'Pausada', color: 'text-orange-600 bg-orange-50', icon: AlertCircle },
|
||||
cancelled: { label: 'Cancelada', color: 'text-red-600 bg-red-50', icon: XCircle },
|
||||
};
|
||||
|
||||
export default function SuscripcionPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { data: subscription, isLoading } = useSubscription(user?.tenantId);
|
||||
const { data: payments } = usePaymentHistory(user?.tenantId);
|
||||
|
||||
const status = statusConfig[subscription?.status || ''] || statusConfig.pending;
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Suscripción" />
|
||||
<main className="p-6 space-y-6">
|
||||
{/* Subscription Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
Estado de Suscripción
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-muted rounded w-1/3" />
|
||||
<div className="h-4 bg-muted rounded w-1/2" />
|
||||
</div>
|
||||
) : subscription ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Plan</p>
|
||||
<p className="text-lg font-semibold capitalize">{subscription.plan}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Estado</p>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium ${status.color}`}>
|
||||
<StatusIcon className="h-4 w-4" />
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Monto Mensual</p>
|
||||
<p className="text-lg font-semibold">
|
||||
${Number(subscription.amount).toLocaleString('es-MX')} MXN
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No se encontró información de suscripción.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payment History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Historial de Pagos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{payments && payments.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Fecha</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Monto</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Estado</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Método</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id} className="border-b last:border-0">
|
||||
<td className="py-2 px-3">
|
||||
{new Date(payment.createdAt).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
${Number(payment.amount).toLocaleString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
payment.status === 'approved'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: payment.status === 'rejected'
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-yellow-50 text-yellow-700'
|
||||
}`}>
|
||||
{payment.status === 'approved' ? 'Aprobado' :
|
||||
payment.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-muted-foreground">
|
||||
{payment.paymentMethod || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
No hay pagos registrados aún.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,23 +16,33 @@ import {
|
||||
Users,
|
||||
Building2,
|
||||
UserCog,
|
||||
CreditCard,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { logout } from '@/lib/api/auth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { hasFeature, type Plan } from '@horux/shared';
|
||||
|
||||
const navigation = [
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
feature?: string; // Required plan feature — hidden if tenant's plan lacks it
|
||||
}
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'CFDI', href: '/cfdi', icon: FileText },
|
||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
||||
{ name: 'Reportes', href: '/reportes', icon: BarChart3 },
|
||||
{ name: 'Calendario', href: '/calendario', icon: Calendar },
|
||||
{ name: 'Alertas', href: '/alertas', icon: Bell },
|
||||
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes' },
|
||||
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users },
|
||||
{ name: 'Suscripción', href: '/configuracion/suscripcion', icon: CreditCard },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings },
|
||||
];
|
||||
|
||||
const adminNavigation = [
|
||||
const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
];
|
||||
@@ -53,9 +63,15 @@ export function Sidebar() {
|
||||
}
|
||||
};
|
||||
|
||||
// Filter navigation based on tenant plan features
|
||||
const plan = (user?.plan || 'starter') as Plan;
|
||||
const filteredNav = navigation.filter(
|
||||
(item) => !item.feature || hasFeature(plan, item.feature)
|
||||
);
|
||||
|
||||
const allNavigation = user?.role === 'admin'
|
||||
? [...navigation.slice(0, -1), ...adminNavigation, navigation[navigation.length - 1]]
|
||||
: navigation;
|
||||
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
||||
: filteredNav;
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
||||
|
||||
44
apps/web/lib/api/subscription.ts
Normal file
44
apps/web/lib/api/subscription.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
plan: string;
|
||||
status: string;
|
||||
amount: string;
|
||||
frequency: string;
|
||||
mpPreapprovalId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
subscriptionId: string | null;
|
||||
mpPaymentId: string | null;
|
||||
amount: string;
|
||||
status: string;
|
||||
paymentMethod: string | null;
|
||||
paidAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function getSubscription(tenantId: string): Promise<Subscription> {
|
||||
const response = await apiClient.get<Subscription>(`/subscriptions/${tenantId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function generatePaymentLink(tenantId: string): Promise<{ paymentUrl: string }> {
|
||||
const response = await apiClient.post<{ paymentUrl: string }>(`/subscriptions/${tenantId}/generate-link`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function markAsPaid(tenantId: string, amount: number): Promise<Payment> {
|
||||
const response = await apiClient.post<Payment>(`/subscriptions/${tenantId}/mark-paid`, { amount });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getPaymentHistory(tenantId: string): Promise<Payment[]> {
|
||||
const response = await apiClient.get<Payment[]>(`/subscriptions/${tenantId}/payments`);
|
||||
return response.data;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export interface Tenant {
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
plan: string;
|
||||
schemaName: string;
|
||||
databaseName: string;
|
||||
createdAt: string;
|
||||
_count?: {
|
||||
users: number;
|
||||
|
||||
40
apps/web/lib/hooks/use-subscription.ts
Normal file
40
apps/web/lib/hooks/use-subscription.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as subscriptionApi from '../api/subscription';
|
||||
|
||||
export function useSubscription(tenantId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['subscription', tenantId],
|
||||
queryFn: () => subscriptionApi.getSubscription(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePaymentHistory(tenantId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['payments', tenantId],
|
||||
queryFn: () => subscriptionApi.getPaymentHistory(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGeneratePaymentLink() {
|
||||
return useMutation({
|
||||
mutationFn: (tenantId: string) => subscriptionApi.generatePaymentLink(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAsPaid() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ tenantId, amount }: { tenantId: string; amount: number }) =>
|
||||
subscriptionApi.markAsPaid(tenantId, amount),
|
||||
onSuccess: (_, { tenantId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', tenantId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['payments', tenantId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
89
deploy/nginx/horux360.conf
Normal file
89
deploy/nginx/horux360.conf
Normal file
@@ -0,0 +1,89 @@
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/s;
|
||||
limit_req_zone $binary_remote_addr zone=webhook:10m rate=10r/s;
|
||||
|
||||
upstream horux_api {
|
||||
server 127.0.0.1:4000;
|
||||
}
|
||||
|
||||
upstream horux_web {
|
||||
server 127.0.0.1:3000;
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name horux360.consultoria-as.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name horux360.consultoria-as.com;
|
||||
|
||||
# SSL (managed by Certbot)
|
||||
ssl_certificate /etc/letsencrypt/live/horux360.consultoria-as.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/horux360.consultoria-as.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Max body size for XML uploads
|
||||
client_max_body_size 1G;
|
||||
|
||||
# Auth endpoints (stricter rate limiting)
|
||||
location /api/auth/ {
|
||||
limit_req zone=auth burst=10 nodelay;
|
||||
proxy_pass http://horux_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Webhook endpoints (no auth, moderate rate limiting)
|
||||
location /api/webhooks/ {
|
||||
limit_req zone=webhook burst=20 nodelay;
|
||||
proxy_pass http://horux_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# API routes
|
||||
location /api/ {
|
||||
limit_req zone=api burst=50 nodelay;
|
||||
proxy_pass http://horux_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Health check (no rate limit)
|
||||
location /health {
|
||||
proxy_pass http://horux_api;
|
||||
}
|
||||
|
||||
# Next.js frontend
|
||||
location / {
|
||||
proxy_pass http://horux_web;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
@@ -2,35 +2,33 @@ module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'horux-api',
|
||||
script: 'dist/index.js',
|
||||
cwd: '/root/Horux/apps/api',
|
||||
script: 'pnpm',
|
||||
args: 'dev',
|
||||
interpreter: 'none',
|
||||
watch: false,
|
||||
instances: 2,
|
||||
exec_mode: 'cluster',
|
||||
autorestart: true,
|
||||
restart_delay: 10000,
|
||||
max_restarts: 3,
|
||||
max_memory_restart: '1G',
|
||||
kill_timeout: 5000,
|
||||
listen_timeout: 10000,
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
PORT: 4000
|
||||
}
|
||||
NODE_ENV: 'production',
|
||||
PORT: 4000,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'horux-web',
|
||||
script: 'node_modules/.bin/next',
|
||||
args: 'start',
|
||||
cwd: '/root/Horux/apps/web',
|
||||
script: 'pnpm',
|
||||
args: 'dev',
|
||||
interpreter: 'none',
|
||||
watch: false,
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
restart_delay: 10000,
|
||||
max_restarts: 3,
|
||||
max_memory_restart: '512M',
|
||||
kill_timeout: 5000,
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
PORT: 3000
|
||||
}
|
||||
}
|
||||
]
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3000,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface UserInfo {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
tenantRfc: string;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export interface JWTPayload {
|
||||
@@ -36,7 +37,7 @@ export interface JWTPayload {
|
||||
email: string;
|
||||
role: Role;
|
||||
tenantId: string;
|
||||
schemaName: string;
|
||||
databaseName: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface Tenant {
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
plan: Plan;
|
||||
schemaName: string;
|
||||
databaseName: string;
|
||||
cfdiLimit: number;
|
||||
usersLimit: number;
|
||||
active: boolean;
|
||||
@@ -20,3 +20,29 @@ export interface TenantUsage {
|
||||
usersLimit: number;
|
||||
plan: Plan;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
plan: Plan;
|
||||
mpPreapprovalId?: string;
|
||||
status: 'pending' | 'authorized' | 'paused' | 'cancelled';
|
||||
amount: number;
|
||||
frequency: 'monthly' | 'yearly';
|
||||
currentPeriodStart?: string;
|
||||
currentPeriodEnd?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
subscriptionId?: string;
|
||||
mpPaymentId?: string;
|
||||
amount: number;
|
||||
status: 'approved' | 'pending' | 'rejected' | 'refunded';
|
||||
paymentMethod?: string;
|
||||
paidAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
84
pnpm-lock.yaml
generated
84
pnpm-lock.yaml
generated
@@ -62,12 +62,21 @@ importers:
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.3
|
||||
mercadopago:
|
||||
specifier: ^2.12.0
|
||||
version: 2.12.0
|
||||
node-cron:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
node-forge:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3
|
||||
nodemailer:
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
pg:
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
zod:
|
||||
specifier: ^3.23.0
|
||||
version: 3.25.76
|
||||
@@ -96,6 +105,12 @@ importers:
|
||||
'@types/node-forge':
|
||||
specifier: ^1.3.14
|
||||
version: 1.3.14
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.11
|
||||
version: 7.0.11
|
||||
'@types/pg':
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
prisma:
|
||||
specifier: ^5.22.0
|
||||
version: 5.22.0
|
||||
@@ -1103,9 +1118,15 @@ packages:
|
||||
'@types/node@22.19.7':
|
||||
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
||||
|
||||
'@types/nodemailer@7.0.11':
|
||||
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
|
||||
|
||||
'@types/pako@2.0.4':
|
||||
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||
|
||||
'@types/pg@8.18.0':
|
||||
resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
@@ -1863,6 +1884,9 @@ packages:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mercadopago@2.12.0:
|
||||
resolution: {integrity: sha512-9S+ZB/Fltd4BV9/U79r7U/+LrYJP844kxxvtAlVbbeVmhOE9rZt0YhPy1GXO3Yf4XyQaHwZ/SCyL2kebAicaLw==}
|
||||
|
||||
merge-descriptors@1.0.3:
|
||||
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
|
||||
|
||||
@@ -1945,6 +1969,15 @@ packages:
|
||||
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
node-forge@1.3.3:
|
||||
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
|
||||
engines: {node: '>= 6.13.0'}
|
||||
@@ -1952,6 +1985,10 @@ packages:
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
nodemailer@8.0.2:
|
||||
resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2422,6 +2459,9 @@ packages:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
traverse@0.3.9:
|
||||
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
|
||||
|
||||
@@ -2537,6 +2577,10 @@ packages:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
hasBin: true
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2544,6 +2588,12 @@ packages:
|
||||
victory-vendor@36.9.2:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -3367,8 +3417,18 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/nodemailer@7.0.11':
|
||||
dependencies:
|
||||
'@types/node': 22.19.7
|
||||
|
||||
'@types/pako@2.0.4': {}
|
||||
|
||||
'@types/pg@8.18.0':
|
||||
dependencies:
|
||||
'@types/node': 22.19.7
|
||||
pg-protocol: 1.11.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/qs@6.14.0': {}
|
||||
@@ -4184,6 +4244,13 @@ snapshots:
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
mercadopago@2.12.0:
|
||||
dependencies:
|
||||
node-fetch: 2.7.0
|
||||
uuid: 9.0.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
merge-descriptors@1.0.3: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
@@ -4258,10 +4325,16 @@ snapshots:
|
||||
|
||||
node-cron@4.2.1: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-forge@1.3.3: {}
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
nodemailer@8.0.2: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
@@ -4749,6 +4822,8 @@ snapshots:
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
traverse@0.3.9: {}
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
@@ -4850,6 +4925,8 @@ snapshots:
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
victory-vendor@36.9.2:
|
||||
@@ -4869,6 +4946,13 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
71
scripts/backup.sh
Executable file
71
scripts/backup.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# Horux360 Database Backup Script
|
||||
# Backs up all databases (central + tenant) with daily/weekly rotation
|
||||
# Requires: .pgpass file at /root/.pgpass with format: localhost:5432:*:postgres:<password>
|
||||
#
|
||||
# Usage: Add to crontab:
|
||||
# 0 1 * * * /root/Horux/scripts/backup.sh >> /var/log/horux-backup.log 2>&1
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="/var/horux/backups"
|
||||
DAILY_DIR="$BACKUP_DIR/daily"
|
||||
WEEKLY_DIR="$BACKUP_DIR/weekly"
|
||||
PG_USER="postgres"
|
||||
PG_HOST="localhost"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
DAY_OF_WEEK=$(date +%u)
|
||||
|
||||
# Retention
|
||||
DAILY_KEEP=7
|
||||
WEEKLY_KEEP=4
|
||||
|
||||
echo "=== Horux360 Backup Started: $(date) ==="
|
||||
|
||||
# Create directories
|
||||
mkdir -p "$DAILY_DIR" "$WEEKLY_DIR"
|
||||
|
||||
# Get list of all horux databases (central + tenant)
|
||||
DATABASES=$(psql -h "$PG_HOST" -U "$PG_USER" -t -c \
|
||||
"SELECT datname FROM pg_database WHERE datname = 'horux360' OR datname LIKE 'horux_%' AND datname NOT LIKE '%_deleted_%'" \
|
||||
| tr -d ' ')
|
||||
|
||||
TOTAL=0
|
||||
ERRORS=0
|
||||
|
||||
for DB in $DATABASES; do
|
||||
echo "Backing up: $DB"
|
||||
DUMP_FILE="$DAILY_DIR/${DB}_${DATE}.sql.gz"
|
||||
|
||||
if pg_dump -h "$PG_HOST" -U "$PG_USER" "$DB" | gzip > "$DUMP_FILE"; then
|
||||
# Verify file is not empty
|
||||
if [ -s "$DUMP_FILE" ]; then
|
||||
TOTAL=$((TOTAL + 1))
|
||||
echo " OK: $(du -h "$DUMP_FILE" | cut -f1)"
|
||||
else
|
||||
echo " WARNING: Empty backup file for $DB"
|
||||
rm -f "$DUMP_FILE"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
else
|
||||
echo " ERROR: Failed to backup $DB"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Weekly backup on Sundays (day 7)
|
||||
if [ "$DAY_OF_WEEK" -eq 7 ]; then
|
||||
echo "Creating weekly backup..."
|
||||
cp "$DAILY_DIR"/*_"${DATE}".sql.gz "$WEEKLY_DIR/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Clean old daily backups
|
||||
echo "Cleaning daily backups older than $DAILY_KEEP days..."
|
||||
find "$DAILY_DIR" -name "*.sql.gz" -mtime "+$DAILY_KEEP" -delete
|
||||
|
||||
# Clean old weekly backups
|
||||
echo "Cleaning weekly backups older than $WEEKLY_KEEP weeks..."
|
||||
find "$WEEKLY_DIR" -name "*.sql.gz" -mtime "+$((WEEKLY_KEEP * 7))" -delete
|
||||
|
||||
echo "=== Backup Complete: $TOTAL databases backed up, $ERRORS errors ==="
|
||||
echo "=== Finished: $(date) ==="
|
||||
27
scripts/tune-postgres.sh
Executable file
27
scripts/tune-postgres.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# PostgreSQL Production Tuning for Horux360
|
||||
# Server: 32GB RAM, 8 cores
|
||||
# Target: 50 tenants, PM2 cluster ×2
|
||||
#
|
||||
# Run once: sudo bash scripts/tune-postgres.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== PostgreSQL Production Tuning ==="
|
||||
|
||||
sudo -u postgres psql -c "ALTER SYSTEM SET max_connections = 300;"
|
||||
sudo -u postgres psql -c "ALTER SYSTEM SET shared_buffers = '4GB';"
|
||||
sudo -u postgres psql -c "ALTER SYSTEM SET work_mem = '16MB';"
|
||||
sudo -u postgres psql -c "ALTER SYSTEM SET effective_cache_size = '16GB';"
|
||||
sudo -u postgres psql -c "ALTER SYSTEM SET maintenance_work_mem = '512MB';"
|
||||
sudo -u postgres psql -c "ALTER SYSTEM SET checkpoint_completion_target = 0.9;"
|
||||
sudo -u postgres psql -c "ALTER SYSTEM SET wal_buffers = '64MB';"
|
||||
sudo -u postgres psql -c "ALTER SYSTEM SET random_page_cost = 1.1;"
|
||||
|
||||
echo "Settings applied. Restarting PostgreSQL..."
|
||||
sudo systemctl restart postgresql
|
||||
|
||||
echo "Verifying settings..."
|
||||
sudo -u postgres psql -c "SHOW max_connections; SHOW shared_buffers; SHOW work_mem;"
|
||||
|
||||
echo "=== Done ==="
|
||||
Reference in New Issue
Block a user