feat: Implement Phase 1 & 2 - Full monorepo architecture
## Backend API (apps/api) - Express.js server with TypeScript - JWT authentication with access/refresh tokens - Multi-tenant middleware (schema per tenant) - Complete CRUD routes: auth, cfdis, transactions, contacts, categories, metrics, alerts - SAT integration: CFDI 4.0 XML parser, FIEL authentication - Metrics engine: 50+ financial metrics (Core, Startup, Enterprise) - Rate limiting, CORS, Helmet security ## Frontend Web (apps/web) - Next.js 14 with App Router - Authentication pages: login, register, forgot-password - Dashboard layout with Sidebar and Header - Dashboard pages: overview, cash-flow, revenue, expenses, metrics - Zustand stores for auth and UI state - Theme support with flash prevention ## Database Package (packages/database) - PostgreSQL migrations with multi-tenant architecture - Public schema: plans, tenants, users, sessions, subscriptions - Tenant schema: sat_credentials, cfdis, transactions, contacts, accounts, alerts - Tenant management functions - Seed data for plans and super admin ## Shared Package (packages/shared) - TypeScript types: auth, tenant, financial, metrics, reports - Zod validation schemas for all entities - Utility functions for formatting ## UI Package (packages/ui) - Chart components: LineChart, BarChart, AreaChart, PieChart - Data components: DataTable, MetricCard, KPICard, AlertBadge - PeriodSelector and Skeleton components ## Infrastructure - Docker Compose: PostgreSQL 15, Redis 7, MinIO, Mailhog - Makefile with 25+ development commands - Development scripts: dev-setup.sh, dev-down.sh - Complete .env.example template Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
406
packages/database/src/connection.ts
Normal file
406
packages/database/src/connection.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* PostgreSQL Connection Pool with Multi-Tenant Support
|
||||
*
|
||||
* Implements a connection pool manager that supports schema-based multi-tenancy.
|
||||
* Each tenant has their own schema, and connections can be scoped to a specific tenant.
|
||||
*/
|
||||
|
||||
import { Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow } from 'pg';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Database configuration interface
|
||||
export interface DatabaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
ssl?: boolean | { rejectUnauthorized: boolean };
|
||||
max?: number;
|
||||
idleTimeoutMillis?: number;
|
||||
connectionTimeoutMillis?: number;
|
||||
}
|
||||
|
||||
// Tenant context for queries
|
||||
export interface TenantContext {
|
||||
tenantId: string;
|
||||
schemaName: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// Query options
|
||||
export interface QueryOptions {
|
||||
tenant?: TenantContext;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// Connection pool events
|
||||
export interface PoolEvents {
|
||||
connect: (client: PoolClient) => void;
|
||||
acquire: (client: PoolClient) => void;
|
||||
release: (client: PoolClient) => void;
|
||||
error: (error: Error, client: PoolClient) => void;
|
||||
remove: (client: PoolClient) => void;
|
||||
}
|
||||
|
||||
// Singleton pool instance
|
||||
let globalPool: Pool | null = null;
|
||||
|
||||
// Pool statistics
|
||||
interface PoolStats {
|
||||
totalConnections: number;
|
||||
idleConnections: number;
|
||||
waitingRequests: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database connection manager with multi-tenant support
|
||||
*/
|
||||
export class DatabaseConnection extends EventEmitter {
|
||||
private pool: Pool;
|
||||
private config: DatabaseConfig;
|
||||
private isConnected: boolean = false;
|
||||
|
||||
constructor(config?: DatabaseConfig) {
|
||||
super();
|
||||
|
||||
this.config = config || this.getConfigFromEnv();
|
||||
|
||||
const poolConfig: PoolConfig = {
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
database: this.config.database,
|
||||
user: this.config.user,
|
||||
password: this.config.password,
|
||||
ssl: this.config.ssl,
|
||||
max: this.config.max || 20,
|
||||
idleTimeoutMillis: this.config.idleTimeoutMillis || 30000,
|
||||
connectionTimeoutMillis: this.config.connectionTimeoutMillis || 10000,
|
||||
};
|
||||
|
||||
this.pool = new Pool(poolConfig);
|
||||
this.setupPoolEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration from environment variables
|
||||
*/
|
||||
private getConfigFromEnv(): DatabaseConfig {
|
||||
return {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
database: process.env.DB_NAME || 'horux_strategy',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
ssl: process.env.DB_SSL === 'true'
|
||||
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
|
||||
: false,
|
||||
max: parseInt(process.env.DB_POOL_MAX || '20', 10),
|
||||
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
|
||||
connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT || '10000', 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup pool event handlers
|
||||
*/
|
||||
private setupPoolEvents(): void {
|
||||
this.pool.on('connect', (client) => {
|
||||
this.isConnected = true;
|
||||
this.emit('connect', client);
|
||||
});
|
||||
|
||||
this.pool.on('acquire', (client) => {
|
||||
this.emit('acquire', client);
|
||||
});
|
||||
|
||||
this.pool.on('release', (client) => {
|
||||
this.emit('release', client);
|
||||
});
|
||||
|
||||
this.pool.on('error', (err, client) => {
|
||||
console.error('Unexpected pool error:', err);
|
||||
this.emit('error', err, client);
|
||||
});
|
||||
|
||||
this.pool.on('remove', (client) => {
|
||||
this.emit('remove', client);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test database connection
|
||||
*/
|
||||
async connect(): Promise<boolean> {
|
||||
try {
|
||||
const client = await this.pool.connect();
|
||||
await client.query('SELECT 1');
|
||||
client.release();
|
||||
this.isConnected = true;
|
||||
console.log('Database connection established successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to database:', error);
|
||||
this.isConnected = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections in the pool
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
await this.pool.end();
|
||||
this.isConnected = false;
|
||||
console.log('Database connection pool closed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pool statistics
|
||||
*/
|
||||
getStats(): PoolStats {
|
||||
return {
|
||||
totalConnections: this.pool.totalCount,
|
||||
idleConnections: this.pool.idleCount,
|
||||
waitingRequests: this.pool.waitingCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
isPoolConnected(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query with optional tenant context
|
||||
*/
|
||||
async query<T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[],
|
||||
options?: QueryOptions
|
||||
): Promise<QueryResult<T>> {
|
||||
const client = await this.pool.connect();
|
||||
|
||||
try {
|
||||
// Set search path for tenant if provided
|
||||
if (options?.tenant) {
|
||||
await client.query(`SET search_path TO ${this.escapeIdentifier(options.tenant.schemaName)}, public`);
|
||||
}
|
||||
|
||||
// Set query timeout if provided
|
||||
if (options?.timeout) {
|
||||
await client.query(`SET statement_timeout = ${options.timeout}`);
|
||||
}
|
||||
|
||||
const result = await client.query<T>(text, params);
|
||||
return result;
|
||||
} finally {
|
||||
// Reset search path and timeout before releasing
|
||||
if (options?.tenant || options?.timeout) {
|
||||
await client.query('RESET search_path; RESET statement_timeout;').catch(() => {});
|
||||
}
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query in the public schema
|
||||
*/
|
||||
async queryPublic<T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<QueryResult<T>> {
|
||||
return this.query<T>(text, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query in a tenant schema
|
||||
*/
|
||||
async queryTenant<T extends QueryResultRow = QueryResultRow>(
|
||||
tenant: TenantContext,
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<QueryResult<T>> {
|
||||
return this.query<T>(text, params, { tenant });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a client for transaction handling
|
||||
*/
|
||||
async getClient(): Promise<PoolClient> {
|
||||
return this.pool.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function within a transaction
|
||||
*/
|
||||
async transaction<T>(
|
||||
fn: (client: PoolClient) => Promise<T>,
|
||||
options?: QueryOptions
|
||||
): Promise<T> {
|
||||
const client = await this.pool.connect();
|
||||
|
||||
try {
|
||||
// Set search path for tenant if provided
|
||||
if (options?.tenant) {
|
||||
await client.query(`SET search_path TO ${this.escapeIdentifier(options.tenant.schemaName)}, public`);
|
||||
}
|
||||
|
||||
await client.query('BEGIN');
|
||||
|
||||
try {
|
||||
const result = await fn(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (options?.tenant) {
|
||||
await client.query('RESET search_path').catch(() => {});
|
||||
}
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function within a tenant transaction
|
||||
*/
|
||||
async tenantTransaction<T>(
|
||||
tenant: TenantContext,
|
||||
fn: (client: PoolClient) => Promise<T>
|
||||
): Promise<T> {
|
||||
return this.transaction(fn, { tenant });
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape an identifier (schema name, table name, etc.)
|
||||
*/
|
||||
private escapeIdentifier(identifier: string): string {
|
||||
// Validate identifier format (alphanumeric and underscores only)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
|
||||
throw new Error(`Invalid identifier: ${identifier}`);
|
||||
}
|
||||
return `"${identifier}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a schema exists
|
||||
*/
|
||||
async schemaExists(schemaName: string): Promise<boolean> {
|
||||
const result = await this.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1) as exists`,
|
||||
[schemaName]
|
||||
);
|
||||
return result.rows[0]?.exists ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all tenant schemas
|
||||
*/
|
||||
async getTenantSchemas(): Promise<string[]> {
|
||||
const result = await this.query<{ schema_name: string }>(
|
||||
`SELECT schema_name FROM information_schema.schemata
|
||||
WHERE schema_name LIKE 'tenant_%'
|
||||
ORDER BY schema_name`
|
||||
);
|
||||
return result.rows.map(row => row.schema_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying pool (use with caution)
|
||||
*/
|
||||
getPool(): Pool {
|
||||
return this.pool;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the global database connection
|
||||
*/
|
||||
export function getDatabase(config?: DatabaseConfig): DatabaseConnection {
|
||||
if (!globalPool) {
|
||||
const connection = new DatabaseConnection(config);
|
||||
globalPool = connection.getPool();
|
||||
return connection;
|
||||
}
|
||||
|
||||
return new DatabaseConnection(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new database connection (non-singleton)
|
||||
*/
|
||||
export function createDatabase(config?: DatabaseConfig): DatabaseConnection {
|
||||
return new DatabaseConnection(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant-scoped database client
|
||||
* Provides a simplified interface for tenant-specific operations
|
||||
*/
|
||||
export class TenantDatabase {
|
||||
private db: DatabaseConnection;
|
||||
private tenant: TenantContext;
|
||||
|
||||
constructor(db: DatabaseConnection, tenant: TenantContext) {
|
||||
this.db = db;
|
||||
this.tenant = tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query in the tenant schema
|
||||
*/
|
||||
async query<T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<QueryResult<T>> {
|
||||
return this.db.queryTenant<T>(this.tenant, text, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction in the tenant schema
|
||||
*/
|
||||
async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
return this.db.tenantTransaction(this.tenant, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant context
|
||||
*/
|
||||
getContext(): TenantContext {
|
||||
return this.tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the public schema (for cross-tenant operations)
|
||||
*/
|
||||
async queryPublic<T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<QueryResult<T>> {
|
||||
return this.db.queryPublic<T>(text, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tenant-scoped database client
|
||||
*/
|
||||
export function createTenantDatabase(
|
||||
db: DatabaseConnection,
|
||||
tenantId: string,
|
||||
userId?: string
|
||||
): TenantDatabase {
|
||||
const tenant: TenantContext = {
|
||||
tenantId,
|
||||
schemaName: `tenant_${tenantId}`,
|
||||
userId,
|
||||
};
|
||||
return new TenantDatabase(db, tenant);
|
||||
}
|
||||
|
||||
// Export types
|
||||
export type { Pool, PoolClient, QueryResult, QueryResultRow };
|
||||
676
packages/database/src/index.ts
Normal file
676
packages/database/src/index.ts
Normal file
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* @horux/database
|
||||
*
|
||||
* Database package for Horux Strategy - CFO Digital para Empresas Mexicanas
|
||||
*
|
||||
* Provides:
|
||||
* - PostgreSQL connection pool with multi-tenant support
|
||||
* - Tenant schema management (create, delete, suspend)
|
||||
* - Migration utilities
|
||||
* - Type definitions for database entities
|
||||
*/
|
||||
|
||||
// Connection management
|
||||
export {
|
||||
DatabaseConnection,
|
||||
TenantDatabase,
|
||||
getDatabase,
|
||||
createDatabase,
|
||||
createTenantDatabase,
|
||||
type DatabaseConfig,
|
||||
type TenantContext,
|
||||
type QueryOptions,
|
||||
type Pool,
|
||||
type PoolClient,
|
||||
type QueryResult,
|
||||
type QueryResultRow,
|
||||
} from './connection.js';
|
||||
|
||||
// Tenant management
|
||||
export {
|
||||
createTenantSchema,
|
||||
deleteTenantSchema,
|
||||
suspendTenant,
|
||||
reactivateTenant,
|
||||
getTenant,
|
||||
getTenantBySlug,
|
||||
listTenants,
|
||||
updateTenantSettings,
|
||||
validateTenantAccess,
|
||||
getSchemaName,
|
||||
createTenantContext,
|
||||
type CreateTenantOptions,
|
||||
type TenantSettings,
|
||||
type TenantInfo,
|
||||
type TenantStatus,
|
||||
} from './tenant.js';
|
||||
|
||||
// Migration utilities (for programmatic use)
|
||||
export {
|
||||
runMigrations,
|
||||
printStatus as getMigrationStatus,
|
||||
rollbackLast as rollbackMigration,
|
||||
ensureDatabase,
|
||||
loadMigrationFiles,
|
||||
getExecutedMigrations,
|
||||
ensureMigrationsTable,
|
||||
type MigrationFile,
|
||||
type MigrationRecord,
|
||||
} from './migrate.js';
|
||||
|
||||
// Seed data exports
|
||||
export {
|
||||
PLANS,
|
||||
SYSTEM_SETTINGS,
|
||||
} from './seed.js';
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions for Database Entities
|
||||
// ============================================================================
|
||||
|
||||
// User roles
|
||||
export type UserRole = 'super_admin' | 'owner' | 'admin' | 'manager' | 'analyst' | 'viewer';
|
||||
|
||||
// Subscription status
|
||||
export type SubscriptionStatus = 'active' | 'trial' | 'past_due' | 'cancelled' | 'suspended' | 'expired';
|
||||
|
||||
// Job status
|
||||
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
// Transaction types
|
||||
export type TransactionType = 'income' | 'expense' | 'transfer' | 'adjustment';
|
||||
export type TransactionStatus = 'pending' | 'confirmed' | 'reconciled' | 'voided';
|
||||
|
||||
// CFDI types
|
||||
export type CfdiStatus = 'active' | 'cancelled' | 'pending_cancellation';
|
||||
export type CfdiType = 'I' | 'E' | 'T' | 'N' | 'P'; // Ingreso, Egreso, Traslado, Nomina, Pago
|
||||
|
||||
// Contact types
|
||||
export type ContactType = 'customer' | 'supplier' | 'both' | 'employee';
|
||||
|
||||
// Category types
|
||||
export type CategoryType = 'income' | 'expense' | 'cost' | 'other';
|
||||
|
||||
// Account types
|
||||
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense';
|
||||
|
||||
// Alert severity
|
||||
export type AlertSeverity = 'info' | 'warning' | 'critical';
|
||||
|
||||
// Report status
|
||||
export type ReportStatus = 'draft' | 'generating' | 'completed' | 'failed' | 'archived';
|
||||
|
||||
// ============================================================================
|
||||
// Entity Interfaces
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Plan entity
|
||||
*/
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
priceMonthly: number;
|
||||
priceYearly: number;
|
||||
maxUsers: number;
|
||||
maxCfdisMonthly: number;
|
||||
maxStorageMb: number;
|
||||
maxApiCallsDaily: number;
|
||||
maxReportsMonthly: number;
|
||||
features: Record<string, boolean>;
|
||||
hasSatSync: boolean;
|
||||
hasBankSync: boolean;
|
||||
hasAiInsights: boolean;
|
||||
hasCustomReports: boolean;
|
||||
hasApiAccess: boolean;
|
||||
hasWhiteLabel: boolean;
|
||||
hasPrioritySupport: boolean;
|
||||
hasDedicatedAccountManager: boolean;
|
||||
dataRetentionMonths: number;
|
||||
displayOrder: number;
|
||||
isActive: boolean;
|
||||
isPopular: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* User entity
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
phone: string | null;
|
||||
avatarUrl: string | null;
|
||||
defaultRole: UserRole;
|
||||
isActive: boolean;
|
||||
isEmailVerified: boolean;
|
||||
emailVerifiedAt: Date | null;
|
||||
twoFactorEnabled: boolean;
|
||||
preferences: Record<string, unknown>;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
lastLoginAt: Date | null;
|
||||
lastLoginIp: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant entity
|
||||
*/
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
schemaName: string;
|
||||
rfc: string | null;
|
||||
razonSocial: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
ownerId: string;
|
||||
planId: string;
|
||||
status: TenantStatus;
|
||||
settings: TenantSettings;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription entity
|
||||
*/
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
planId: string;
|
||||
status: SubscriptionStatus;
|
||||
billingCycle: 'monthly' | 'yearly';
|
||||
trialEndsAt: Date | null;
|
||||
currentPeriodStart: Date;
|
||||
currentPeriodEnd: Date;
|
||||
cancelledAt: Date | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
paymentProcessor: string | null;
|
||||
externalSubscriptionId: string | null;
|
||||
externalCustomerId: string | null;
|
||||
priceCents: number;
|
||||
currency: string;
|
||||
usageCfdisCurrent: number;
|
||||
usageStorageMbCurrent: number;
|
||||
usageApiCallsCurrent: number;
|
||||
usageResetAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* User session entity
|
||||
*/
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string | null;
|
||||
userAgent: string | null;
|
||||
ipAddress: string | null;
|
||||
deviceType: string | null;
|
||||
deviceName: string | null;
|
||||
locationCity: string | null;
|
||||
locationCountry: string | null;
|
||||
isActive: boolean;
|
||||
expiresAt: Date;
|
||||
refreshExpiresAt: Date | null;
|
||||
lastActivityAt: Date;
|
||||
createdAt: Date;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background job entity
|
||||
*/
|
||||
export interface BackgroundJob {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
userId: string | null;
|
||||
jobType: string;
|
||||
jobName: string | null;
|
||||
queue: string;
|
||||
priority: number;
|
||||
payload: Record<string, unknown>;
|
||||
status: JobStatus;
|
||||
progress: number;
|
||||
result: Record<string, unknown> | null;
|
||||
errorMessage: string | null;
|
||||
errorStack: string | null;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
scheduledAt: Date;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
timeoutSeconds: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* API key entity
|
||||
*/
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
keyPrefix: string;
|
||||
scopes: string[];
|
||||
allowedIps: string[] | null;
|
||||
allowedOrigins: string[] | null;
|
||||
rateLimitPerMinute: number;
|
||||
rateLimitPerDay: number;
|
||||
lastUsedAt: Date | null;
|
||||
usageCount: number;
|
||||
isActive: boolean;
|
||||
expiresAt: Date | null;
|
||||
createdBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
revokedAt: Date | null;
|
||||
revokedBy: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit log entry entity
|
||||
*/
|
||||
export interface AuditLogEntry {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
userId: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string | null;
|
||||
oldValues: Record<string, unknown> | null;
|
||||
newValues: Record<string, unknown> | null;
|
||||
details: Record<string, unknown> | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
requestId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Schema Entity Interfaces
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SAT credentials entity (tenant schema)
|
||||
*/
|
||||
export interface SatCredentials {
|
||||
id: string;
|
||||
rfc: string;
|
||||
cerSerialNumber: string | null;
|
||||
cerIssuedAt: Date | null;
|
||||
cerExpiresAt: Date | null;
|
||||
cerIssuer: string | null;
|
||||
isActive: boolean;
|
||||
isValid: boolean;
|
||||
lastValidatedAt: Date | null;
|
||||
validationError: string | null;
|
||||
syncEnabled: boolean;
|
||||
syncFrequencyHours: number;
|
||||
lastSyncAt: Date | null;
|
||||
lastSyncStatus: string | null;
|
||||
lastSyncError: string | null;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* CFDI entity (tenant schema)
|
||||
*/
|
||||
export interface Cfdi {
|
||||
id: string;
|
||||
uuidFiscal: string;
|
||||
serie: string | null;
|
||||
folio: string | null;
|
||||
tipoComprobante: CfdiType;
|
||||
status: CfdiStatus;
|
||||
fechaEmision: Date;
|
||||
fechaTimbrado: Date | null;
|
||||
fechaCancelacion: Date | null;
|
||||
emisorRfc: string;
|
||||
emisorNombre: string;
|
||||
emisorRegimenFiscal: string;
|
||||
receptorRfc: string;
|
||||
receptorNombre: string;
|
||||
receptorRegimenFiscal: string | null;
|
||||
receptorDomicilioFiscal: string | null;
|
||||
receptorUsoCfdi: string;
|
||||
subtotal: number;
|
||||
descuento: number;
|
||||
total: number;
|
||||
totalImpuestosTrasladados: number;
|
||||
totalImpuestosRetenidos: number;
|
||||
iva16: number;
|
||||
iva8: number;
|
||||
iva0: number;
|
||||
ivaExento: number;
|
||||
isrRetenido: number;
|
||||
ivaRetenido: number;
|
||||
moneda: string;
|
||||
tipoCambio: number;
|
||||
formaPago: string | null;
|
||||
metodoPago: string | null;
|
||||
condicionesPago: string | null;
|
||||
cfdiRelacionados: Record<string, unknown> | null;
|
||||
tipoRelacion: string | null;
|
||||
conceptos: Record<string, unknown>[];
|
||||
isEmitted: boolean;
|
||||
categoryId: string | null;
|
||||
contactId: string | null;
|
||||
isReconciled: boolean;
|
||||
reconciledAt: Date | null;
|
||||
reconciledBy: string | null;
|
||||
aiCategorySuggestion: string | null;
|
||||
aiConfidenceScore: number | null;
|
||||
source: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaction entity (tenant schema)
|
||||
*/
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
type: TransactionType;
|
||||
status: TransactionStatus;
|
||||
amount: number;
|
||||
currency: string;
|
||||
exchangeRate: number;
|
||||
amountMxn: number;
|
||||
transactionDate: Date;
|
||||
valueDate: Date | null;
|
||||
recordedAt: Date;
|
||||
description: string | null;
|
||||
reference: string | null;
|
||||
notes: string | null;
|
||||
categoryId: string | null;
|
||||
accountId: string | null;
|
||||
contactId: string | null;
|
||||
cfdiId: string | null;
|
||||
bankTransactionId: string | null;
|
||||
bankAccountId: string | null;
|
||||
bankDescription: string | null;
|
||||
isRecurring: boolean;
|
||||
recurringPattern: Record<string, unknown> | null;
|
||||
parentTransactionId: string | null;
|
||||
attachments: Record<string, unknown>[] | null;
|
||||
tags: string[] | null;
|
||||
isReconciled: boolean;
|
||||
reconciledAt: Date | null;
|
||||
reconciledBy: string | null;
|
||||
requiresApproval: boolean;
|
||||
approvedAt: Date | null;
|
||||
approvedBy: string | null;
|
||||
aiCategoryId: string | null;
|
||||
aiConfidence: number | null;
|
||||
aiNotes: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
voidedAt: Date | null;
|
||||
voidedBy: string | null;
|
||||
voidReason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact entity (tenant schema)
|
||||
*/
|
||||
export interface Contact {
|
||||
id: string;
|
||||
type: ContactType;
|
||||
name: string;
|
||||
tradeName: string | null;
|
||||
rfc: string | null;
|
||||
regimenFiscal: string | null;
|
||||
usoCfdiDefault: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
website: string | null;
|
||||
addressStreet: string | null;
|
||||
addressInterior: string | null;
|
||||
addressExterior: string | null;
|
||||
addressNeighborhood: string | null;
|
||||
addressCity: string | null;
|
||||
addressMunicipality: string | null;
|
||||
addressState: string | null;
|
||||
addressZip: string | null;
|
||||
addressCountry: string;
|
||||
bankName: string | null;
|
||||
bankAccount: string | null;
|
||||
bankClabe: string | null;
|
||||
creditDays: number;
|
||||
creditLimit: number;
|
||||
balanceReceivable: number;
|
||||
balancePayable: number;
|
||||
category: string | null;
|
||||
tags: string[] | null;
|
||||
isActive: boolean;
|
||||
notes: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category entity (tenant schema)
|
||||
*/
|
||||
export interface Category {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: CategoryType;
|
||||
parentId: string | null;
|
||||
level: number;
|
||||
path: string | null;
|
||||
satKey: string | null;
|
||||
budgetMonthly: number | null;
|
||||
budgetYearly: number | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
displayOrder: number;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account entity (tenant schema)
|
||||
*/
|
||||
export interface Account {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: AccountType;
|
||||
parentId: string | null;
|
||||
level: number;
|
||||
path: string | null;
|
||||
satCode: string | null;
|
||||
satNature: 'D' | 'A' | null;
|
||||
balanceDebit: number;
|
||||
balanceCredit: number;
|
||||
balanceCurrent: number;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
allowsMovements: boolean;
|
||||
displayOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert entity (tenant schema)
|
||||
*/
|
||||
export interface Alert {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: AlertSeverity;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
thresholdType: string | null;
|
||||
thresholdValue: number | null;
|
||||
currentValue: number | null;
|
||||
actionUrl: string | null;
|
||||
actionLabel: string | null;
|
||||
actionData: Record<string, unknown> | null;
|
||||
isRead: boolean;
|
||||
isDismissed: boolean;
|
||||
readAt: Date | null;
|
||||
dismissedAt: Date | null;
|
||||
dismissedBy: string | null;
|
||||
isRecurring: boolean;
|
||||
lastTriggeredAt: Date | null;
|
||||
triggerCount: number;
|
||||
autoResolved: boolean;
|
||||
resolvedAt: Date | null;
|
||||
resolvedBy: string | null;
|
||||
resolutionNotes: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report entity (tenant schema)
|
||||
*/
|
||||
export interface Report {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
comparisonPeriodStart: Date | null;
|
||||
comparisonPeriodEnd: Date | null;
|
||||
status: ReportStatus;
|
||||
parameters: Record<string, unknown> | null;
|
||||
data: Record<string, unknown> | null;
|
||||
fileUrl: string | null;
|
||||
fileFormat: string | null;
|
||||
isScheduled: boolean;
|
||||
scheduleCron: string | null;
|
||||
nextScheduledAt: Date | null;
|
||||
lastGeneratedAt: Date | null;
|
||||
isShared: boolean;
|
||||
sharedWith: string[] | null;
|
||||
shareToken: string | null;
|
||||
shareExpiresAt: Date | null;
|
||||
generatedBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric cache entry entity (tenant schema)
|
||||
*/
|
||||
export interface MetricCache {
|
||||
id: string;
|
||||
metricKey: string;
|
||||
periodType: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
dimensionType: string | null;
|
||||
dimensionId: string | null;
|
||||
valueNumeric: number | null;
|
||||
valueJson: Record<string, unknown> | null;
|
||||
previousValue: number | null;
|
||||
changePercent: number | null;
|
||||
changeAbsolute: number | null;
|
||||
computedAt: Date;
|
||||
validUntil: Date | null;
|
||||
isStale: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setting entity (tenant schema)
|
||||
*/
|
||||
export interface Setting {
|
||||
key: string;
|
||||
value: string;
|
||||
valueType: 'string' | 'integer' | 'boolean' | 'json';
|
||||
category: string;
|
||||
label: string | null;
|
||||
description: string | null;
|
||||
isSensitive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bank account entity (tenant schema)
|
||||
*/
|
||||
export interface BankAccount {
|
||||
id: string;
|
||||
bankName: string;
|
||||
bankCode: string | null;
|
||||
accountNumber: string | null;
|
||||
clabe: string | null;
|
||||
accountType: string | null;
|
||||
alias: string | null;
|
||||
currency: string;
|
||||
balanceAvailable: number | null;
|
||||
balanceCurrent: number | null;
|
||||
balanceUpdatedAt: Date | null;
|
||||
connectionProvider: string | null;
|
||||
connectionId: string | null;
|
||||
connectionStatus: string | null;
|
||||
lastSyncAt: Date | null;
|
||||
lastSyncError: string | null;
|
||||
accountId: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget item entity (tenant schema)
|
||||
*/
|
||||
export interface BudgetItem {
|
||||
id: string;
|
||||
year: number;
|
||||
month: number;
|
||||
categoryId: string | null;
|
||||
accountId: string | null;
|
||||
amountBudgeted: number;
|
||||
amountActual: number;
|
||||
amountVariance: number;
|
||||
notes: string | null;
|
||||
isLocked: boolean;
|
||||
createdBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment entity (tenant schema)
|
||||
*/
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
fileName: string;
|
||||
fileType: string | null;
|
||||
fileSize: number | null;
|
||||
fileUrl: string;
|
||||
storageProvider: string;
|
||||
storagePath: string | null;
|
||||
uploadedBy: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
462
packages/database/src/migrate.ts
Normal file
462
packages/database/src/migrate.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Database Migration Script
|
||||
*
|
||||
* Executes SQL migrations against the database.
|
||||
* Supports both public schema and tenant schema migrations.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Get directory path for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Migration tracking table
|
||||
const MIGRATIONS_TABLE = '_migrations';
|
||||
|
||||
// Database configuration
|
||||
interface DatabaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
ssl?: boolean | { rejectUnauthorized: boolean };
|
||||
}
|
||||
|
||||
// Migration record
|
||||
interface MigrationRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
executed_at: Date;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
// Migration file
|
||||
interface MigrationFile {
|
||||
name: string;
|
||||
path: string;
|
||||
content: string;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database configuration from environment
|
||||
*/
|
||||
function getConfig(): DatabaseConfig {
|
||||
return {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
database: process.env.DB_NAME || 'horux_strategy',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
ssl: process.env.DB_SSL === 'true'
|
||||
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate simple checksum for migration content
|
||||
*/
|
||||
function calculateChecksum(content: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const char = content.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create migrations tracking table if it doesn't exist
|
||||
*/
|
||||
async function ensureMigrationsTable(client: PoolClient): Promise<void> {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
checksum VARCHAR(20) NOT NULL,
|
||||
execution_time_ms INTEGER
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of executed migrations
|
||||
*/
|
||||
async function getExecutedMigrations(client: PoolClient): Promise<MigrationRecord[]> {
|
||||
const result = await client.query<MigrationRecord>(
|
||||
`SELECT id, name, executed_at, checksum FROM ${MIGRATIONS_TABLE} ORDER BY id`
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load migration files from directory
|
||||
*/
|
||||
function loadMigrationFiles(migrationsDir: string): MigrationFile[] {
|
||||
if (!existsSync(migrationsDir)) {
|
||||
console.log(`Migrations directory not found: ${migrationsDir}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = readdirSync(migrationsDir)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
return files.map(file => {
|
||||
const filePath = join(migrationsDir, file);
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
return {
|
||||
name: file,
|
||||
path: filePath,
|
||||
content,
|
||||
checksum: calculateChecksum(content),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single migration
|
||||
*/
|
||||
async function executeMigration(
|
||||
client: PoolClient,
|
||||
migration: MigrationFile
|
||||
): Promise<number> {
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(` Executing: ${migration.name}`);
|
||||
|
||||
// Execute the migration SQL
|
||||
await client.query(migration.content);
|
||||
|
||||
// Record the migration
|
||||
await client.query(
|
||||
`INSERT INTO ${MIGRATIONS_TABLE} (name, checksum, execution_time_ms)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[migration.name, migration.checksum, Date.now() - startTime]
|
||||
);
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.log(` Completed: ${migration.name} (${executionTime}ms)`);
|
||||
|
||||
return executionTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run pending migrations
|
||||
*/
|
||||
async function runMigrations(
|
||||
pool: Pool,
|
||||
migrationsDir: string,
|
||||
options: { force?: boolean; dryRun?: boolean } = {}
|
||||
): Promise<{ executed: string[]; skipped: string[]; errors: string[] }> {
|
||||
const result = {
|
||||
executed: [] as string[],
|
||||
skipped: [] as string[],
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// Ensure migrations table exists
|
||||
await ensureMigrationsTable(client);
|
||||
|
||||
// Get executed migrations
|
||||
const executed = await getExecutedMigrations(client);
|
||||
const executedNames = new Set(executed.map(m => m.name));
|
||||
const executedChecksums = new Map(executed.map(m => [m.name, m.checksum]));
|
||||
|
||||
// Load migration files
|
||||
const migrations = loadMigrationFiles(migrationsDir);
|
||||
|
||||
if (migrations.length === 0) {
|
||||
console.log('No migration files found.');
|
||||
return result;
|
||||
}
|
||||
|
||||
console.log(`Found ${migrations.length} migration file(s)`);
|
||||
|
||||
// Check for checksum mismatches
|
||||
for (const migration of migrations) {
|
||||
if (executedNames.has(migration.name)) {
|
||||
const previousChecksum = executedChecksums.get(migration.name);
|
||||
if (previousChecksum !== migration.checksum && !options.force) {
|
||||
const errorMsg = `Checksum mismatch for ${migration.name}. Migration was modified after execution.`;
|
||||
console.error(`ERROR: ${errorMsg}`);
|
||||
result.errors.push(errorMsg);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute pending migrations
|
||||
await client.query('BEGIN');
|
||||
|
||||
try {
|
||||
for (const migration of migrations) {
|
||||
if (executedNames.has(migration.name)) {
|
||||
console.log(` Skipping: ${migration.name} (already executed)`);
|
||||
result.skipped.push(migration.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(` Would execute: ${migration.name}`);
|
||||
result.executed.push(migration.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
await executeMigration(client, migration);
|
||||
result.executed.push(migration.name);
|
||||
}
|
||||
|
||||
if (!options.dryRun) {
|
||||
await client.query('COMMIT');
|
||||
} else {
|
||||
await client.query('ROLLBACK');
|
||||
console.log('\nDry run complete. No changes were made.');
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print migration status
|
||||
*/
|
||||
async function printStatus(pool: Pool, migrationsDir: string): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await ensureMigrationsTable(client);
|
||||
|
||||
const executed = await getExecutedMigrations(client);
|
||||
const executedNames = new Set(executed.map(m => m.name));
|
||||
|
||||
const migrations = loadMigrationFiles(migrationsDir);
|
||||
|
||||
console.log('\nMigration Status:');
|
||||
console.log('=================\n');
|
||||
|
||||
if (migrations.length === 0) {
|
||||
console.log('No migration files found.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const migration of migrations) {
|
||||
const status = executedNames.has(migration.name) ? '[x]' : '[ ]';
|
||||
const executedRecord = executed.find(e => e.name === migration.name);
|
||||
const date = executedRecord
|
||||
? executedRecord.executed_at.toISOString()
|
||||
: 'pending';
|
||||
console.log(`${status} ${migration.name} - ${date}`);
|
||||
}
|
||||
|
||||
const pending = migrations.filter(m => !executedNames.has(m.name));
|
||||
console.log(`\nTotal: ${migrations.length}, Executed: ${executed.length}, Pending: ${pending.length}`);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback last migration
|
||||
*/
|
||||
async function rollbackLast(pool: Pool): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await ensureMigrationsTable(client);
|
||||
|
||||
const result = await client.query<MigrationRecord>(
|
||||
`SELECT id, name FROM ${MIGRATIONS_TABLE} ORDER BY id DESC LIMIT 1`
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
console.log('No migrations to rollback.');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMigration = result.rows[0];
|
||||
|
||||
await client.query('BEGIN');
|
||||
|
||||
try {
|
||||
// Delete migration record
|
||||
await client.query(`DELETE FROM ${MIGRATIONS_TABLE} WHERE id = $1`, [lastMigration.id]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
console.log(`Rolled back: ${lastMigration.name}`);
|
||||
console.log('\nNote: The SQL changes were NOT reversed. You need to manually undo the schema changes.');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database if it doesn't exist
|
||||
*/
|
||||
async function ensureDatabase(config: DatabaseConfig): Promise<void> {
|
||||
// Connect to postgres database to create the target database
|
||||
const adminPool = new Pool({
|
||||
...config,
|
||||
database: 'postgres',
|
||||
});
|
||||
|
||||
try {
|
||||
const client = await adminPool.connect();
|
||||
|
||||
try {
|
||||
// Check if database exists
|
||||
const result = await client.query(
|
||||
'SELECT 1 FROM pg_database WHERE datname = $1',
|
||||
[config.database]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
// Create database
|
||||
console.log(`Creating database: ${config.database}`);
|
||||
await client.query(`CREATE DATABASE "${config.database}"`);
|
||||
console.log(`Database created: ${config.database}`);
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main CLI
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0] || 'up';
|
||||
|
||||
const config = getConfig();
|
||||
const migrationsDir = join(__dirname, 'migrations');
|
||||
|
||||
console.log(`\nHorux Strategy - Database Migration Tool`);
|
||||
console.log(`========================================`);
|
||||
console.log(`Database: ${config.database}@${config.host}:${config.port}`);
|
||||
console.log(`Migrations: ${migrationsDir}\n`);
|
||||
|
||||
// Ensure database exists
|
||||
if (command !== 'status') {
|
||||
try {
|
||||
await ensureDatabase(config);
|
||||
} catch (error) {
|
||||
console.error('Error ensuring database exists:', error);
|
||||
// Continue anyway, might not have permission to create DB
|
||||
}
|
||||
}
|
||||
|
||||
const pool = new Pool(config);
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'up':
|
||||
case 'migrate': {
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const force = args.includes('--force');
|
||||
|
||||
console.log('Running migrations...');
|
||||
if (dryRun) console.log('(Dry run mode)\n');
|
||||
|
||||
const result = await runMigrations(pool, migrationsDir, { dryRun, force });
|
||||
|
||||
console.log('\nSummary:');
|
||||
console.log(` Executed: ${result.executed.length}`);
|
||||
console.log(` Skipped: ${result.skipped.length}`);
|
||||
console.log(` Errors: ${result.errors.length}`);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
await printStatus(pool, migrationsDir);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rollback': {
|
||||
console.log('Rolling back last migration...\n');
|
||||
await rollbackLast(pool);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'help':
|
||||
default: {
|
||||
console.log(`
|
||||
Usage: migrate [command] [options]
|
||||
|
||||
Commands:
|
||||
up, migrate Run pending migrations (default)
|
||||
status Show migration status
|
||||
rollback Rollback the last migration record
|
||||
help Show this help message
|
||||
|
||||
Options:
|
||||
--dry-run Show what would be executed without making changes
|
||||
--force Ignore checksum mismatches
|
||||
|
||||
Environment Variables:
|
||||
DB_HOST Database host (default: localhost)
|
||||
DB_PORT Database port (default: 5432)
|
||||
DB_NAME Database name (default: horux_strategy)
|
||||
DB_USER Database user (default: postgres)
|
||||
DB_PASSWORD Database password
|
||||
DB_SSL Enable SSL (default: false)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\nMigration failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
main().catch(console.error);
|
||||
|
||||
// Export for programmatic use
|
||||
export {
|
||||
runMigrations,
|
||||
printStatus,
|
||||
rollbackLast,
|
||||
ensureDatabase,
|
||||
loadMigrationFiles,
|
||||
getExecutedMigrations,
|
||||
ensureMigrationsTable,
|
||||
MigrationFile,
|
||||
MigrationRecord,
|
||||
};
|
||||
658
packages/database/src/migrations/001_public_schema.sql
Normal file
658
packages/database/src/migrations/001_public_schema.sql
Normal file
@@ -0,0 +1,658 @@
|
||||
-- ============================================================================
|
||||
-- Horux Strategy - Public Schema Migration
|
||||
-- Version: 001
|
||||
-- Description: Core tables for multi-tenant SaaS platform
|
||||
-- ============================================================================
|
||||
|
||||
-- Enable required extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- For text search
|
||||
|
||||
-- ============================================================================
|
||||
-- ENUM TYPES
|
||||
-- ============================================================================
|
||||
|
||||
-- User roles enum
|
||||
CREATE TYPE user_role AS ENUM (
|
||||
'super_admin', -- Platform administrator (Horux team)
|
||||
'owner', -- Tenant owner (company owner)
|
||||
'admin', -- Tenant administrator
|
||||
'manager', -- Department manager
|
||||
'analyst', -- Financial analyst (read + limited write)
|
||||
'viewer' -- Read-only access
|
||||
);
|
||||
|
||||
-- Subscription status enum
|
||||
CREATE TYPE subscription_status AS ENUM (
|
||||
'active',
|
||||
'trial',
|
||||
'past_due',
|
||||
'cancelled',
|
||||
'suspended',
|
||||
'expired'
|
||||
);
|
||||
|
||||
-- Tenant status enum
|
||||
CREATE TYPE tenant_status AS ENUM (
|
||||
'active',
|
||||
'suspended',
|
||||
'pending',
|
||||
'deleted'
|
||||
);
|
||||
|
||||
-- Job status enum
|
||||
CREATE TYPE job_status AS ENUM (
|
||||
'pending',
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- PLANS TABLE
|
||||
-- Subscription plans available in the platform
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE plans (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Pricing (in MXN cents to avoid floating point issues)
|
||||
price_monthly_cents INTEGER NOT NULL DEFAULT 0,
|
||||
price_yearly_cents INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Limits
|
||||
max_users INTEGER NOT NULL DEFAULT 1,
|
||||
max_cfdis_monthly INTEGER NOT NULL DEFAULT 100,
|
||||
max_storage_mb INTEGER NOT NULL DEFAULT 1024,
|
||||
max_api_calls_daily INTEGER NOT NULL DEFAULT 1000,
|
||||
max_reports_monthly INTEGER NOT NULL DEFAULT 10,
|
||||
|
||||
-- Features (JSON for flexibility)
|
||||
features JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Feature flags
|
||||
has_sat_sync BOOLEAN NOT NULL DEFAULT false,
|
||||
has_bank_sync BOOLEAN NOT NULL DEFAULT false,
|
||||
has_ai_insights BOOLEAN NOT NULL DEFAULT false,
|
||||
has_custom_reports BOOLEAN NOT NULL DEFAULT false,
|
||||
has_api_access BOOLEAN NOT NULL DEFAULT false,
|
||||
has_white_label BOOLEAN NOT NULL DEFAULT false,
|
||||
has_priority_support BOOLEAN NOT NULL DEFAULT false,
|
||||
has_dedicated_account_manager BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Retention
|
||||
data_retention_months INTEGER NOT NULL DEFAULT 12,
|
||||
|
||||
-- Display
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
is_popular BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create index for active plans
|
||||
CREATE INDEX idx_plans_active ON plans(is_active, display_order);
|
||||
|
||||
-- ============================================================================
|
||||
-- TENANTS TABLE
|
||||
-- Companies/organizations using the platform
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Basic info
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
schema_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
|
||||
-- Tax info (Mexican RFC)
|
||||
rfc VARCHAR(13),
|
||||
razon_social VARCHAR(500),
|
||||
|
||||
-- Contact
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
|
||||
-- Address
|
||||
address_street VARCHAR(500),
|
||||
address_city VARCHAR(100),
|
||||
address_state VARCHAR(100),
|
||||
address_zip VARCHAR(10),
|
||||
address_country VARCHAR(2) DEFAULT 'MX',
|
||||
|
||||
-- Branding
|
||||
logo_url VARCHAR(500),
|
||||
primary_color VARCHAR(7),
|
||||
|
||||
-- Owner and plan
|
||||
owner_id UUID NOT NULL,
|
||||
plan_id VARCHAR(50) NOT NULL REFERENCES plans(id),
|
||||
|
||||
-- Status
|
||||
status tenant_status NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- Settings (JSON for flexibility)
|
||||
settings JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Indexes for tenants
|
||||
CREATE INDEX idx_tenants_slug ON tenants(slug);
|
||||
CREATE INDEX idx_tenants_rfc ON tenants(rfc) WHERE rfc IS NOT NULL;
|
||||
CREATE INDEX idx_tenants_owner ON tenants(owner_id);
|
||||
CREATE INDEX idx_tenants_plan ON tenants(plan_id);
|
||||
CREATE INDEX idx_tenants_status ON tenants(status);
|
||||
CREATE INDEX idx_tenants_created ON tenants(created_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- USERS TABLE
|
||||
-- Platform users (can belong to multiple tenants via user_tenants)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Authentication
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255),
|
||||
|
||||
-- Profile
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
phone VARCHAR(50),
|
||||
avatar_url VARCHAR(500),
|
||||
|
||||
-- Default role (for super_admin only)
|
||||
default_role user_role NOT NULL DEFAULT 'viewer',
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
is_email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
email_verified_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Security
|
||||
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMP WITH TIME ZONE,
|
||||
password_changed_at TIMESTAMP WITH TIME ZONE,
|
||||
must_change_password BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Two-factor authentication
|
||||
two_factor_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
two_factor_secret VARCHAR(255),
|
||||
two_factor_recovery_codes TEXT[],
|
||||
|
||||
-- Preferences
|
||||
preferences JSONB NOT NULL DEFAULT '{}',
|
||||
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
|
||||
locale VARCHAR(10) DEFAULT 'es-MX',
|
||||
|
||||
-- Metadata
|
||||
last_login_at TIMESTAMP WITH TIME ZONE,
|
||||
last_login_ip INET,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for users
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_active ON users(is_active);
|
||||
CREATE INDEX idx_users_default_role ON users(default_role) WHERE default_role = 'super_admin';
|
||||
CREATE INDEX idx_users_last_login ON users(last_login_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- USER_TENANTS TABLE
|
||||
-- Association between users and tenants with role
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE user_tenants (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Role within this tenant
|
||||
role user_role NOT NULL DEFAULT 'viewer',
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Invitation
|
||||
invited_by UUID REFERENCES users(id),
|
||||
invited_at TIMESTAMP WITH TIME ZONE,
|
||||
accepted_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
|
||||
PRIMARY KEY (user_id, tenant_id)
|
||||
);
|
||||
|
||||
-- Indexes for user_tenants
|
||||
CREATE INDEX idx_user_tenants_tenant ON user_tenants(tenant_id);
|
||||
CREATE INDEX idx_user_tenants_user ON user_tenants(user_id);
|
||||
CREATE INDEX idx_user_tenants_role ON user_tenants(role);
|
||||
|
||||
-- ============================================================================
|
||||
-- USER_SESSIONS TABLE
|
||||
-- Active user sessions for authentication
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE user_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Session token (hashed)
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
refresh_token_hash VARCHAR(255),
|
||||
|
||||
-- Device info
|
||||
user_agent TEXT,
|
||||
ip_address INET,
|
||||
device_type VARCHAR(50),
|
||||
device_name VARCHAR(255),
|
||||
|
||||
-- Location (approximate)
|
||||
location_city VARCHAR(100),
|
||||
location_country VARCHAR(2),
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Timestamps
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
refresh_expires_at TIMESTAMP WITH TIME ZONE,
|
||||
last_activity_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
revoked_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Indexes for user_sessions
|
||||
CREATE INDEX idx_user_sessions_user ON user_sessions(user_id);
|
||||
CREATE INDEX idx_user_sessions_tenant ON user_sessions(tenant_id);
|
||||
CREATE INDEX idx_user_sessions_token ON user_sessions(token_hash);
|
||||
CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at) WHERE is_active = true;
|
||||
CREATE INDEX idx_user_sessions_active ON user_sessions(user_id, is_active) WHERE is_active = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- SUBSCRIPTIONS TABLE
|
||||
-- Tenant subscription management
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
plan_id VARCHAR(50) NOT NULL REFERENCES plans(id),
|
||||
|
||||
-- Status
|
||||
status subscription_status NOT NULL DEFAULT 'trial',
|
||||
|
||||
-- Billing cycle
|
||||
billing_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly', -- monthly, yearly
|
||||
|
||||
-- Dates
|
||||
trial_ends_at TIMESTAMP WITH TIME ZONE,
|
||||
current_period_start TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
current_period_end TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
cancelled_at TIMESTAMP WITH TIME ZONE,
|
||||
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Payment info (Stripe or other processor)
|
||||
payment_processor VARCHAR(50),
|
||||
external_subscription_id VARCHAR(255),
|
||||
external_customer_id VARCHAR(255),
|
||||
|
||||
-- Pricing at subscription time
|
||||
price_cents INTEGER NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'MXN',
|
||||
|
||||
-- Usage tracking
|
||||
usage_cfdis_current INTEGER NOT NULL DEFAULT 0,
|
||||
usage_storage_mb_current DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
usage_api_calls_current INTEGER NOT NULL DEFAULT 0,
|
||||
usage_reset_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for subscriptions
|
||||
CREATE INDEX idx_subscriptions_tenant ON subscriptions(tenant_id);
|
||||
CREATE INDEX idx_subscriptions_plan ON subscriptions(plan_id);
|
||||
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
|
||||
CREATE INDEX idx_subscriptions_period_end ON subscriptions(current_period_end);
|
||||
CREATE INDEX idx_subscriptions_external ON subscriptions(external_subscription_id) WHERE external_subscription_id IS NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- AUDIT_LOG TABLE
|
||||
-- Comprehensive audit trail for compliance
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Context
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Action info
|
||||
action VARCHAR(100) NOT NULL,
|
||||
entity_type VARCHAR(100) NOT NULL,
|
||||
entity_id VARCHAR(255),
|
||||
|
||||
-- Change tracking
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
details JSONB,
|
||||
|
||||
-- Request context
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
request_id VARCHAR(100),
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for audit_log (optimized for queries)
|
||||
CREATE INDEX idx_audit_log_tenant ON audit_log(tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_log_user ON audit_log(user_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_log_action ON audit_log(action, created_at DESC);
|
||||
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
|
||||
|
||||
-- Partition audit_log by month for better performance (optional, can be enabled later)
|
||||
-- This is a placeholder comment - actual partitioning would require more setup
|
||||
|
||||
-- ============================================================================
|
||||
-- BACKGROUND_JOBS TABLE
|
||||
-- Async job queue for long-running tasks
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE background_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Context
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Job info
|
||||
job_type VARCHAR(100) NOT NULL,
|
||||
job_name VARCHAR(255),
|
||||
queue VARCHAR(50) NOT NULL DEFAULT 'default',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Payload
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Status
|
||||
status job_status NOT NULL DEFAULT 'pending',
|
||||
progress INTEGER DEFAULT 0, -- 0-100
|
||||
|
||||
-- Results
|
||||
result JSONB,
|
||||
error_message TEXT,
|
||||
error_stack TEXT,
|
||||
|
||||
-- Retry logic
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
|
||||
-- Scheduling
|
||||
scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
started_at TIMESTAMP WITH TIME ZONE,
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Timeout
|
||||
timeout_seconds INTEGER DEFAULT 3600, -- 1 hour default
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for background_jobs
|
||||
CREATE INDEX idx_background_jobs_tenant ON background_jobs(tenant_id);
|
||||
CREATE INDEX idx_background_jobs_status ON background_jobs(status, scheduled_at) WHERE status IN ('pending', 'running');
|
||||
CREATE INDEX idx_background_jobs_queue ON background_jobs(queue, priority DESC, scheduled_at) WHERE status = 'pending';
|
||||
CREATE INDEX idx_background_jobs_type ON background_jobs(job_type, status);
|
||||
CREATE INDEX idx_background_jobs_scheduled ON background_jobs(scheduled_at) WHERE status = 'pending';
|
||||
|
||||
-- ============================================================================
|
||||
-- API_KEYS TABLE
|
||||
-- API keys for external integrations
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Key info
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- The key (only prefix stored for display, full hash for verification)
|
||||
key_prefix VARCHAR(10) NOT NULL, -- First 8 chars for identification
|
||||
key_hash VARCHAR(255) NOT NULL UNIQUE, -- SHA-256 hash of full key
|
||||
|
||||
-- Permissions (scopes)
|
||||
scopes TEXT[] NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Restrictions
|
||||
allowed_ips INET[],
|
||||
allowed_origins TEXT[],
|
||||
|
||||
-- Rate limiting
|
||||
rate_limit_per_minute INTEGER DEFAULT 60,
|
||||
rate_limit_per_day INTEGER DEFAULT 10000,
|
||||
|
||||
-- Usage tracking
|
||||
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||
usage_count BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Expiration
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Metadata
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||
revoked_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Indexes for api_keys
|
||||
CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id);
|
||||
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash) WHERE is_active = true;
|
||||
CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix);
|
||||
CREATE INDEX idx_api_keys_active ON api_keys(tenant_id, is_active) WHERE is_active = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- PASSWORD_RESET_TOKENS TABLE
|
||||
-- Tokens for password reset functionality
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE password_reset_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
used_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for password_reset_tokens
|
||||
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
||||
CREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token_hash) WHERE used_at IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- EMAIL_VERIFICATION_TOKENS TABLE
|
||||
-- Tokens for email verification
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE email_verification_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
verified_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for email_verification_tokens
|
||||
CREATE INDEX idx_email_verification_tokens_user ON email_verification_tokens(user_id);
|
||||
CREATE INDEX idx_email_verification_tokens_token ON email_verification_tokens(token_hash) WHERE verified_at IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- INVITATION_TOKENS TABLE
|
||||
-- Tokens for user invitations to tenants
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE invitation_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
role user_role NOT NULL DEFAULT 'viewer',
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
accepted_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for invitation_tokens
|
||||
CREATE INDEX idx_invitation_tokens_tenant ON invitation_tokens(tenant_id);
|
||||
CREATE INDEX idx_invitation_tokens_email ON invitation_tokens(email);
|
||||
CREATE INDEX idx_invitation_tokens_token ON invitation_tokens(token_hash) WHERE accepted_at IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- SYSTEM_SETTINGS TABLE
|
||||
-- Global platform settings
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE system_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string, integer, boolean, json
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'general',
|
||||
description TEXT,
|
||||
is_sensitive BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- NOTIFICATIONS TABLE
|
||||
-- System and user notifications
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE notifications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Notification content
|
||||
type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT,
|
||||
data JSONB,
|
||||
|
||||
-- Action
|
||||
action_url VARCHAR(500),
|
||||
action_label VARCHAR(100),
|
||||
|
||||
-- Status
|
||||
is_read BOOLEAN NOT NULL DEFAULT false,
|
||||
read_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Delivery
|
||||
channels TEXT[] NOT NULL DEFAULT '{"in_app"}', -- in_app, email, push
|
||||
email_sent_at TIMESTAMP WITH TIME ZONE,
|
||||
push_sent_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Indexes for notifications
|
||||
CREATE INDEX idx_notifications_user ON notifications(user_id, created_at DESC);
|
||||
CREATE INDEX idx_notifications_unread ON notifications(user_id, is_read, created_at DESC) WHERE is_read = false;
|
||||
CREATE INDEX idx_notifications_tenant ON notifications(tenant_id, created_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- FUNCTIONS AND TRIGGERS
|
||||
-- ============================================================================
|
||||
|
||||
-- Function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply update_updated_at trigger to all relevant tables
|
||||
CREATE TRIGGER update_plans_updated_at BEFORE UPDATE ON plans
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_user_tenants_updated_at BEFORE UPDATE ON user_tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_background_jobs_updated_at BEFORE UPDATE ON background_jobs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_api_keys_updated_at BEFORE UPDATE ON api_keys
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_system_settings_updated_at BEFORE UPDATE ON system_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ============================================================================
|
||||
-- ROW LEVEL SECURITY (RLS) POLICIES
|
||||
-- Enable RLS for multi-tenant security
|
||||
-- ============================================================================
|
||||
|
||||
-- Note: RLS policies would be configured here in production
|
||||
-- For now, security is handled at the application layer with schema-based isolation
|
||||
|
||||
-- ============================================================================
|
||||
-- COMMENTS FOR DOCUMENTATION
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON TABLE plans IS 'Subscription plans available in the platform';
|
||||
COMMENT ON TABLE tenants IS 'Companies/organizations using the platform (each gets their own schema)';
|
||||
COMMENT ON TABLE users IS 'Platform users (authentication and profile)';
|
||||
COMMENT ON TABLE user_tenants IS 'Many-to-many relationship between users and tenants with role';
|
||||
COMMENT ON TABLE user_sessions IS 'Active user sessions for JWT-based authentication';
|
||||
COMMENT ON TABLE subscriptions IS 'Tenant subscription and billing information';
|
||||
COMMENT ON TABLE audit_log IS 'Comprehensive audit trail for security and compliance';
|
||||
COMMENT ON TABLE background_jobs IS 'Async job queue for long-running tasks (SAT sync, reports, etc.)';
|
||||
COMMENT ON TABLE api_keys IS 'API keys for external integrations and third-party access';
|
||||
COMMENT ON TABLE notifications IS 'In-app and push notifications for users';
|
||||
889
packages/database/src/migrations/002_tenant_schema.sql
Normal file
889
packages/database/src/migrations/002_tenant_schema.sql
Normal file
@@ -0,0 +1,889 @@
|
||||
-- ============================================================================
|
||||
-- Horux Strategy - Tenant Schema Template
|
||||
-- Version: 002
|
||||
-- Description: Tables created for each tenant in their own schema
|
||||
-- Note: ${SCHEMA_NAME} will be replaced with the actual schema name
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- ENUM TYPES (tenant-specific)
|
||||
-- ============================================================================
|
||||
|
||||
-- Transaction type
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'transaction_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||
CREATE TYPE transaction_type AS ENUM ('income', 'expense', 'transfer', 'adjustment');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- Transaction status
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'transaction_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||
CREATE TYPE transaction_status AS ENUM ('pending', 'confirmed', 'reconciled', 'voided');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- CFDI status
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||
CREATE TYPE cfdi_status AS ENUM ('active', 'cancelled', 'pending_cancellation');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- CFDI type (comprobante)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||
CREATE TYPE cfdi_type AS ENUM ('I', 'E', 'T', 'N', 'P'); -- Ingreso, Egreso, Traslado, Nomina, Pago
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- Contact type
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'contact_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||
CREATE TYPE contact_type AS ENUM ('customer', 'supplier', 'both', 'employee');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- Category type
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'category_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||
CREATE TYPE category_type AS ENUM ('income', 'expense', 'cost', 'other');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- Account type
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'account_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||
CREATE TYPE account_type AS ENUM ('asset', 'liability', 'equity', 'revenue', 'expense');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- Alert severity
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'alert_severity' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||
CREATE TYPE alert_severity AS ENUM ('info', 'warning', 'critical');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- Report status
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'report_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||
CREATE TYPE report_status AS ENUM ('draft', 'generating', 'completed', 'failed', 'archived');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- SAT_CREDENTIALS TABLE
|
||||
-- Encrypted FIEL (e.firma) credentials for SAT integration
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE sat_credentials (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- RFC associated with credentials
|
||||
rfc VARCHAR(13) NOT NULL UNIQUE,
|
||||
|
||||
-- FIEL Components (encrypted with AES-256)
|
||||
-- The actual encryption key is stored securely in environment variables
|
||||
cer_file_encrypted BYTEA NOT NULL, -- .cer file content (encrypted)
|
||||
key_file_encrypted BYTEA NOT NULL, -- .key file content (encrypted)
|
||||
password_encrypted BYTEA NOT NULL, -- FIEL password (encrypted)
|
||||
|
||||
-- Certificate metadata
|
||||
cer_serial_number VARCHAR(50),
|
||||
cer_issued_at TIMESTAMP WITH TIME ZONE,
|
||||
cer_expires_at TIMESTAMP WITH TIME ZONE,
|
||||
cer_issuer VARCHAR(255),
|
||||
|
||||
-- CIEC credentials (optional, for portal access)
|
||||
ciec_password_encrypted BYTEA,
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
is_valid BOOLEAN NOT NULL DEFAULT false, -- Set after validation
|
||||
last_validated_at TIMESTAMP WITH TIME ZONE,
|
||||
validation_error TEXT,
|
||||
|
||||
-- SAT sync settings
|
||||
sync_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
sync_frequency_hours INTEGER DEFAULT 24,
|
||||
last_sync_at TIMESTAMP WITH TIME ZONE,
|
||||
last_sync_status VARCHAR(50),
|
||||
last_sync_error TEXT,
|
||||
|
||||
-- Encryption metadata
|
||||
encryption_version INTEGER NOT NULL DEFAULT 1,
|
||||
encryption_iv BYTEA, -- Initialization vector
|
||||
|
||||
-- Metadata
|
||||
created_by UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for sat_credentials
|
||||
CREATE INDEX idx_sat_credentials_rfc ON sat_credentials(rfc);
|
||||
CREATE INDEX idx_sat_credentials_active ON sat_credentials(is_active, is_valid);
|
||||
CREATE INDEX idx_sat_credentials_sync ON sat_credentials(sync_enabled, last_sync_at) WHERE sync_enabled = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- CFDIS TABLE
|
||||
-- CFDI 4.0 compliant invoice storage
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE cfdis (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- CFDI Identifiers
|
||||
uuid_fiscal UUID NOT NULL UNIQUE, -- UUID from SAT (timbre fiscal)
|
||||
serie VARCHAR(25),
|
||||
folio VARCHAR(40),
|
||||
|
||||
-- Type and status
|
||||
tipo_comprobante cfdi_type NOT NULL, -- I, E, T, N, P
|
||||
status cfdi_status NOT NULL DEFAULT 'active',
|
||||
|
||||
-- Dates
|
||||
fecha_emision TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
fecha_timbrado TIMESTAMP WITH TIME ZONE,
|
||||
fecha_cancelacion TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Emisor (seller)
|
||||
emisor_rfc VARCHAR(13) NOT NULL,
|
||||
emisor_nombre VARCHAR(500) NOT NULL,
|
||||
emisor_regimen_fiscal VARCHAR(3) NOT NULL, -- SAT catalog code
|
||||
|
||||
-- Receptor (buyer)
|
||||
receptor_rfc VARCHAR(13) NOT NULL,
|
||||
receptor_nombre VARCHAR(500) NOT NULL,
|
||||
receptor_regimen_fiscal VARCHAR(3),
|
||||
receptor_domicilio_fiscal VARCHAR(5), -- CP
|
||||
receptor_uso_cfdi VARCHAR(4) NOT NULL, -- SAT catalog code
|
||||
|
||||
-- Amounts
|
||||
subtotal DECIMAL(18,2) NOT NULL,
|
||||
descuento DECIMAL(18,2) DEFAULT 0,
|
||||
total DECIMAL(18,2) NOT NULL,
|
||||
|
||||
-- Tax breakdown
|
||||
total_impuestos_trasladados DECIMAL(18,2) DEFAULT 0,
|
||||
total_impuestos_retenidos DECIMAL(18,2) DEFAULT 0,
|
||||
|
||||
-- IVA specific
|
||||
iva_16 DECIMAL(18,2) DEFAULT 0, -- IVA 16%
|
||||
iva_8 DECIMAL(18,2) DEFAULT 0, -- IVA 8% (frontera)
|
||||
iva_0 DECIMAL(18,2) DEFAULT 0, -- IVA 0%
|
||||
iva_exento DECIMAL(18,2) DEFAULT 0, -- IVA exento
|
||||
|
||||
-- ISR retention
|
||||
isr_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
|
||||
-- Currency
|
||||
moneda VARCHAR(3) NOT NULL DEFAULT 'MXN',
|
||||
tipo_cambio DECIMAL(18,6) DEFAULT 1,
|
||||
|
||||
-- Payment info
|
||||
forma_pago VARCHAR(2), -- SAT catalog
|
||||
metodo_pago VARCHAR(3), -- PUE, PPD
|
||||
condiciones_pago VARCHAR(255),
|
||||
|
||||
-- Related documents
|
||||
cfdi_relacionados JSONB, -- Array of related CFDI UUIDs
|
||||
tipo_relacion VARCHAR(2), -- SAT catalog
|
||||
|
||||
-- Concepts/items (denormalized for quick access)
|
||||
conceptos JSONB NOT NULL, -- Array of line items
|
||||
|
||||
-- Full XML storage
|
||||
xml_content TEXT, -- Original XML
|
||||
xml_hash VARCHAR(64), -- SHA-256 of XML
|
||||
|
||||
-- Digital stamps
|
||||
sello_cfdi TEXT, -- Digital signature
|
||||
sello_sat TEXT, -- SAT signature
|
||||
certificado_sat VARCHAR(50),
|
||||
cadena_original_tfd TEXT,
|
||||
|
||||
-- Direction (for the tenant)
|
||||
is_emitted BOOLEAN NOT NULL, -- true = we issued it, false = we received it
|
||||
|
||||
-- Categorization
|
||||
category_id UUID,
|
||||
contact_id UUID,
|
||||
|
||||
-- Reconciliation
|
||||
is_reconciled BOOLEAN NOT NULL DEFAULT false,
|
||||
reconciled_at TIMESTAMP WITH TIME ZONE,
|
||||
reconciled_by UUID,
|
||||
|
||||
-- AI-generated insights
|
||||
ai_category_suggestion VARCHAR(100),
|
||||
ai_confidence_score DECIMAL(5,4),
|
||||
|
||||
-- Source
|
||||
source VARCHAR(50) NOT NULL DEFAULT 'sat_sync', -- sat_sync, manual, api
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for cfdis (optimized for reporting queries)
|
||||
CREATE INDEX idx_cfdis_uuid_fiscal ON cfdis(uuid_fiscal);
|
||||
CREATE INDEX idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC);
|
||||
CREATE INDEX idx_cfdis_emisor_rfc ON cfdis(emisor_rfc);
|
||||
CREATE INDEX idx_cfdis_receptor_rfc ON cfdis(receptor_rfc);
|
||||
CREATE INDEX idx_cfdis_tipo ON cfdis(tipo_comprobante);
|
||||
CREATE INDEX idx_cfdis_status ON cfdis(status);
|
||||
CREATE INDEX idx_cfdis_is_emitted ON cfdis(is_emitted);
|
||||
CREATE INDEX idx_cfdis_category ON cfdis(category_id) WHERE category_id IS NOT NULL;
|
||||
CREATE INDEX idx_cfdis_contact ON cfdis(contact_id) WHERE contact_id IS NOT NULL;
|
||||
CREATE INDEX idx_cfdis_reconciled ON cfdis(is_reconciled, fecha_emision DESC) WHERE is_reconciled = false;
|
||||
|
||||
-- Composite indexes for common queries
|
||||
CREATE INDEX idx_cfdis_emitted_date ON cfdis(is_emitted, fecha_emision DESC);
|
||||
CREATE INDEX idx_cfdis_type_date ON cfdis(tipo_comprobante, fecha_emision DESC);
|
||||
CREATE INDEX idx_cfdis_month_report ON cfdis(DATE_TRUNC('month', fecha_emision), tipo_comprobante, is_emitted);
|
||||
|
||||
-- Full-text search index
|
||||
CREATE INDEX idx_cfdis_search ON cfdis USING gin(to_tsvector('spanish', emisor_nombre || ' ' || receptor_nombre || ' ' || COALESCE(serie, '') || ' ' || COALESCE(folio, '')));
|
||||
|
||||
-- ============================================================================
|
||||
-- TRANSACTIONS TABLE
|
||||
-- Unified financial transaction model
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE transactions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Transaction info
|
||||
type transaction_type NOT NULL,
|
||||
status transaction_status NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- Amount
|
||||
amount DECIMAL(18,2) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'MXN',
|
||||
exchange_rate DECIMAL(18,6) DEFAULT 1,
|
||||
amount_mxn DECIMAL(18,2) NOT NULL, -- Always in MXN for reporting
|
||||
|
||||
-- Dates
|
||||
transaction_date DATE NOT NULL,
|
||||
value_date DATE, -- Settlement date
|
||||
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Description
|
||||
description TEXT,
|
||||
reference VARCHAR(255),
|
||||
notes TEXT,
|
||||
|
||||
-- Categorization
|
||||
category_id UUID,
|
||||
account_id UUID,
|
||||
contact_id UUID,
|
||||
|
||||
-- Related documents
|
||||
cfdi_id UUID REFERENCES cfdis(id) ON DELETE SET NULL,
|
||||
|
||||
-- Bank reconciliation
|
||||
bank_transaction_id VARCHAR(255),
|
||||
bank_account_id VARCHAR(100),
|
||||
bank_description TEXT,
|
||||
|
||||
-- Recurring
|
||||
is_recurring BOOLEAN NOT NULL DEFAULT false,
|
||||
recurring_pattern JSONB, -- Frequency, end date, etc.
|
||||
parent_transaction_id UUID REFERENCES transactions(id) ON DELETE SET NULL,
|
||||
|
||||
-- Attachments
|
||||
attachments JSONB, -- Array of file references
|
||||
|
||||
-- Tags for custom classification
|
||||
tags TEXT[],
|
||||
|
||||
-- Reconciliation status
|
||||
is_reconciled BOOLEAN NOT NULL DEFAULT false,
|
||||
reconciled_at TIMESTAMP WITH TIME ZONE,
|
||||
reconciled_by UUID,
|
||||
|
||||
-- Approval workflow (for larger amounts)
|
||||
requires_approval BOOLEAN NOT NULL DEFAULT false,
|
||||
approved_at TIMESTAMP WITH TIME ZONE,
|
||||
approved_by UUID,
|
||||
|
||||
-- AI categorization
|
||||
ai_category_id UUID,
|
||||
ai_confidence DECIMAL(5,4),
|
||||
ai_notes TEXT,
|
||||
|
||||
-- Metadata
|
||||
created_by UUID,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
voided_at TIMESTAMP WITH TIME ZONE,
|
||||
voided_by UUID,
|
||||
void_reason TEXT
|
||||
);
|
||||
|
||||
-- Indexes for transactions
|
||||
CREATE INDEX idx_transactions_date ON transactions(transaction_date DESC);
|
||||
CREATE INDEX idx_transactions_type ON transactions(type);
|
||||
CREATE INDEX idx_transactions_status ON transactions(status);
|
||||
CREATE INDEX idx_transactions_category ON transactions(category_id);
|
||||
CREATE INDEX idx_transactions_account ON transactions(account_id);
|
||||
CREATE INDEX idx_transactions_contact ON transactions(contact_id);
|
||||
CREATE INDEX idx_transactions_cfdi ON transactions(cfdi_id) WHERE cfdi_id IS NOT NULL;
|
||||
CREATE INDEX idx_transactions_reconciled ON transactions(is_reconciled, transaction_date DESC) WHERE is_reconciled = false;
|
||||
|
||||
-- Composite indexes for reporting
|
||||
CREATE INDEX idx_transactions_monthly ON transactions(DATE_TRUNC('month', transaction_date), type, status);
|
||||
CREATE INDEX idx_transactions_category_date ON transactions(category_id, transaction_date DESC) WHERE category_id IS NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- CONTACTS TABLE
|
||||
-- Customers and suppliers
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE contacts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Type
|
||||
type contact_type NOT NULL,
|
||||
|
||||
-- Basic info
|
||||
name VARCHAR(500) NOT NULL,
|
||||
trade_name VARCHAR(500), -- Nombre comercial
|
||||
|
||||
-- Tax info (RFC)
|
||||
rfc VARCHAR(13),
|
||||
regimen_fiscal VARCHAR(3), -- SAT catalog
|
||||
uso_cfdi_default VARCHAR(4), -- Default uso CFDI
|
||||
|
||||
-- Contact info
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
mobile VARCHAR(50),
|
||||
website VARCHAR(255),
|
||||
|
||||
-- Address
|
||||
address_street VARCHAR(500),
|
||||
address_interior VARCHAR(50),
|
||||
address_exterior VARCHAR(50),
|
||||
address_neighborhood VARCHAR(200), -- Colonia
|
||||
address_city VARCHAR(100),
|
||||
address_municipality VARCHAR(100), -- Municipio/Delegacion
|
||||
address_state VARCHAR(100),
|
||||
address_zip VARCHAR(5),
|
||||
address_country VARCHAR(2) DEFAULT 'MX',
|
||||
|
||||
-- Bank info
|
||||
bank_name VARCHAR(100),
|
||||
bank_account VARCHAR(20),
|
||||
bank_clabe VARCHAR(18),
|
||||
|
||||
-- Credit terms
|
||||
credit_days INTEGER DEFAULT 0,
|
||||
credit_limit DECIMAL(18,2) DEFAULT 0,
|
||||
|
||||
-- Balances (denormalized for performance)
|
||||
balance_receivable DECIMAL(18,2) DEFAULT 0,
|
||||
balance_payable DECIMAL(18,2) DEFAULT 0,
|
||||
|
||||
-- Classification
|
||||
category VARCHAR(100),
|
||||
tags TEXT[],
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Notes
|
||||
notes TEXT,
|
||||
|
||||
-- Metadata
|
||||
created_by UUID,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for contacts
|
||||
CREATE INDEX idx_contacts_type ON contacts(type);
|
||||
CREATE INDEX idx_contacts_rfc ON contacts(rfc) WHERE rfc IS NOT NULL;
|
||||
CREATE INDEX idx_contacts_name ON contacts(name);
|
||||
CREATE INDEX idx_contacts_active ON contacts(is_active);
|
||||
CREATE INDEX idx_contacts_search ON contacts USING gin(to_tsvector('spanish', name || ' ' || COALESCE(trade_name, '') || ' ' || COALESCE(rfc, '')));
|
||||
|
||||
-- ============================================================================
|
||||
-- CATEGORIES TABLE
|
||||
-- Transaction/expense categories
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE categories (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Identification
|
||||
code VARCHAR(20) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Type
|
||||
type category_type NOT NULL,
|
||||
|
||||
-- Hierarchy
|
||||
parent_id UUID REFERENCES categories(id) ON DELETE SET NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
path TEXT, -- Materialized path for hierarchy
|
||||
|
||||
-- SAT mapping
|
||||
sat_key VARCHAR(10), -- Clave producto/servicio SAT
|
||||
|
||||
-- Budget
|
||||
budget_monthly DECIMAL(18,2),
|
||||
budget_yearly DECIMAL(18,2),
|
||||
|
||||
-- Display
|
||||
color VARCHAR(7), -- Hex color
|
||||
icon VARCHAR(50),
|
||||
display_order INTEGER DEFAULT 0,
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
is_system BOOLEAN NOT NULL DEFAULT false, -- Prevent deletion of system categories
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for categories
|
||||
CREATE INDEX idx_categories_code ON categories(code);
|
||||
CREATE INDEX idx_categories_type ON categories(type);
|
||||
CREATE INDEX idx_categories_parent ON categories(parent_id);
|
||||
CREATE INDEX idx_categories_active ON categories(is_active);
|
||||
|
||||
-- ============================================================================
|
||||
-- ACCOUNTS TABLE
|
||||
-- Chart of accounts (catalogo de cuentas)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE accounts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Identification
|
||||
code VARCHAR(20) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Type
|
||||
type account_type NOT NULL,
|
||||
|
||||
-- Hierarchy
|
||||
parent_id UUID REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
path TEXT, -- Materialized path
|
||||
|
||||
-- SAT mapping (for Contabilidad Electronica)
|
||||
sat_code VARCHAR(20), -- Codigo agrupador SAT
|
||||
sat_nature VARCHAR(1), -- D = Deudora, A = Acreedora
|
||||
|
||||
-- Balances (denormalized)
|
||||
balance_debit DECIMAL(18,2) DEFAULT 0,
|
||||
balance_credit DECIMAL(18,2) DEFAULT 0,
|
||||
balance_current DECIMAL(18,2) DEFAULT 0,
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
is_system BOOLEAN NOT NULL DEFAULT false,
|
||||
allows_movements BOOLEAN NOT NULL DEFAULT true, -- Can have direct transactions
|
||||
|
||||
-- Display
|
||||
display_order INTEGER DEFAULT 0,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for accounts
|
||||
CREATE INDEX idx_accounts_code ON accounts(code);
|
||||
CREATE INDEX idx_accounts_type ON accounts(type);
|
||||
CREATE INDEX idx_accounts_parent ON accounts(parent_id);
|
||||
CREATE INDEX idx_accounts_active ON accounts(is_active);
|
||||
|
||||
-- ============================================================================
|
||||
-- METRICS_CACHE TABLE
|
||||
-- Pre-computed metrics for dashboard performance
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE metrics_cache (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Metric identification
|
||||
metric_key VARCHAR(100) NOT NULL,
|
||||
period_type VARCHAR(20) NOT NULL, -- daily, weekly, monthly, yearly
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
|
||||
-- Dimension (optional filtering)
|
||||
dimension_type VARCHAR(50), -- category, contact, account, etc.
|
||||
dimension_id UUID,
|
||||
|
||||
-- Values
|
||||
value_numeric DECIMAL(18,4),
|
||||
value_json JSONB,
|
||||
|
||||
-- Comparison
|
||||
previous_value DECIMAL(18,4),
|
||||
change_percent DECIMAL(8,4),
|
||||
change_absolute DECIMAL(18,4),
|
||||
|
||||
-- Validity
|
||||
computed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
valid_until TIMESTAMP WITH TIME ZONE,
|
||||
is_stale BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Unique constraint on metric + period + dimension
|
||||
UNIQUE(metric_key, period_type, period_start, dimension_type, dimension_id)
|
||||
);
|
||||
|
||||
-- Indexes for metrics_cache
|
||||
CREATE INDEX idx_metrics_cache_key ON metrics_cache(metric_key);
|
||||
CREATE INDEX idx_metrics_cache_period ON metrics_cache(period_type, period_start DESC);
|
||||
CREATE INDEX idx_metrics_cache_dimension ON metrics_cache(dimension_type, dimension_id) WHERE dimension_type IS NOT NULL;
|
||||
CREATE INDEX idx_metrics_cache_stale ON metrics_cache(is_stale) WHERE is_stale = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- ALERTS TABLE
|
||||
-- Financial alerts and notifications
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE alerts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Alert info
|
||||
type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
severity alert_severity NOT NULL DEFAULT 'info',
|
||||
|
||||
-- Related entity
|
||||
entity_type VARCHAR(50),
|
||||
entity_id UUID,
|
||||
|
||||
-- Threshold that triggered the alert
|
||||
threshold_type VARCHAR(50),
|
||||
threshold_value DECIMAL(18,4),
|
||||
current_value DECIMAL(18,4),
|
||||
|
||||
-- Actions
|
||||
action_url VARCHAR(500),
|
||||
action_label VARCHAR(100),
|
||||
action_data JSONB,
|
||||
|
||||
-- Status
|
||||
is_read BOOLEAN NOT NULL DEFAULT false,
|
||||
is_dismissed BOOLEAN NOT NULL DEFAULT false,
|
||||
read_at TIMESTAMP WITH TIME ZONE,
|
||||
dismissed_at TIMESTAMP WITH TIME ZONE,
|
||||
dismissed_by UUID,
|
||||
|
||||
-- Recurrence
|
||||
is_recurring BOOLEAN NOT NULL DEFAULT false,
|
||||
last_triggered_at TIMESTAMP WITH TIME ZONE,
|
||||
trigger_count INTEGER DEFAULT 1,
|
||||
|
||||
-- Auto-resolve
|
||||
auto_resolved BOOLEAN NOT NULL DEFAULT false,
|
||||
resolved_at TIMESTAMP WITH TIME ZONE,
|
||||
resolved_by UUID,
|
||||
resolution_notes TEXT,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Indexes for alerts
|
||||
CREATE INDEX idx_alerts_type ON alerts(type);
|
||||
CREATE INDEX idx_alerts_severity ON alerts(severity);
|
||||
CREATE INDEX idx_alerts_unread ON alerts(is_read, created_at DESC) WHERE is_read = false;
|
||||
CREATE INDEX idx_alerts_entity ON alerts(entity_type, entity_id) WHERE entity_type IS NOT NULL;
|
||||
CREATE INDEX idx_alerts_active ON alerts(is_dismissed, created_at DESC) WHERE is_dismissed = false;
|
||||
|
||||
-- ============================================================================
|
||||
-- REPORTS TABLE
|
||||
-- Generated reports
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE reports (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Report info
|
||||
type VARCHAR(100) NOT NULL, -- balance_general, estado_resultados, flujo_efectivo, etc.
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Period
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
comparison_period_start DATE,
|
||||
comparison_period_end DATE,
|
||||
|
||||
-- Status
|
||||
status report_status NOT NULL DEFAULT 'draft',
|
||||
|
||||
-- Parameters used to generate
|
||||
parameters JSONB,
|
||||
|
||||
-- Output
|
||||
data JSONB, -- Report data
|
||||
file_url VARCHAR(500), -- PDF/Excel URL
|
||||
file_format VARCHAR(10), -- pdf, xlsx, csv
|
||||
|
||||
-- Scheduling
|
||||
is_scheduled BOOLEAN NOT NULL DEFAULT false,
|
||||
schedule_cron VARCHAR(50),
|
||||
next_scheduled_at TIMESTAMP WITH TIME ZONE,
|
||||
last_generated_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Sharing
|
||||
is_shared BOOLEAN NOT NULL DEFAULT false,
|
||||
shared_with UUID[],
|
||||
share_token VARCHAR(100),
|
||||
share_expires_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Metadata
|
||||
generated_by UUID,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for reports
|
||||
CREATE INDEX idx_reports_type ON reports(type);
|
||||
CREATE INDEX idx_reports_status ON reports(status);
|
||||
CREATE INDEX idx_reports_period ON reports(period_start, period_end);
|
||||
CREATE INDEX idx_reports_scheduled ON reports(is_scheduled, next_scheduled_at) WHERE is_scheduled = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- SETTINGS TABLE
|
||||
-- Tenant-specific settings
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string, integer, boolean, json
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'general',
|
||||
label VARCHAR(200),
|
||||
description TEXT,
|
||||
is_sensitive BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- BANK_ACCOUNTS TABLE
|
||||
-- Connected bank accounts
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE bank_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Bank info
|
||||
bank_name VARCHAR(100) NOT NULL,
|
||||
bank_code VARCHAR(10), -- SPEI bank code
|
||||
|
||||
-- Account info
|
||||
account_number VARCHAR(20),
|
||||
clabe VARCHAR(18),
|
||||
account_type VARCHAR(50), -- checking, savings, credit
|
||||
|
||||
-- Display
|
||||
alias VARCHAR(100),
|
||||
currency VARCHAR(3) DEFAULT 'MXN',
|
||||
|
||||
-- Balance (cached from bank sync)
|
||||
balance_available DECIMAL(18,2),
|
||||
balance_current DECIMAL(18,2),
|
||||
balance_updated_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Connection
|
||||
connection_provider VARCHAR(50), -- belvo, finerio, manual
|
||||
connection_id VARCHAR(255),
|
||||
connection_status VARCHAR(50),
|
||||
last_sync_at TIMESTAMP WITH TIME ZONE,
|
||||
last_sync_error TEXT,
|
||||
|
||||
-- Categorization
|
||||
account_id UUID REFERENCES accounts(id), -- Link to chart of accounts
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for bank_accounts
|
||||
CREATE INDEX idx_bank_accounts_active ON bank_accounts(is_active);
|
||||
CREATE INDEX idx_bank_accounts_connection ON bank_accounts(connection_provider, connection_id) WHERE connection_id IS NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- BUDGET_ITEMS TABLE
|
||||
-- Budget planning
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE budget_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Period
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL, -- 1-12, or 0 for yearly
|
||||
|
||||
-- Category
|
||||
category_id UUID REFERENCES categories(id) ON DELETE CASCADE,
|
||||
account_id UUID REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
|
||||
-- Amounts
|
||||
amount_budgeted DECIMAL(18,2) NOT NULL,
|
||||
amount_actual DECIMAL(18,2) DEFAULT 0,
|
||||
amount_variance DECIMAL(18,2) GENERATED ALWAYS AS (amount_actual - amount_budgeted) STORED,
|
||||
|
||||
-- Notes
|
||||
notes TEXT,
|
||||
|
||||
-- Status
|
||||
is_locked BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Metadata
|
||||
created_by UUID,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- One budget per category/account per period
|
||||
UNIQUE(year, month, category_id),
|
||||
UNIQUE(year, month, account_id)
|
||||
);
|
||||
|
||||
-- Indexes for budget_items
|
||||
CREATE INDEX idx_budget_items_period ON budget_items(year, month);
|
||||
CREATE INDEX idx_budget_items_category ON budget_items(category_id);
|
||||
CREATE INDEX idx_budget_items_account ON budget_items(account_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- ATTACHMENTS TABLE
|
||||
-- File attachments for various entities
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Related entity
|
||||
entity_type VARCHAR(50) NOT NULL, -- cfdi, transaction, contact, etc.
|
||||
entity_id UUID NOT NULL,
|
||||
|
||||
-- File info
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_type VARCHAR(100),
|
||||
file_size INTEGER,
|
||||
file_url VARCHAR(500) NOT NULL,
|
||||
|
||||
-- Storage
|
||||
storage_provider VARCHAR(50) DEFAULT 'local', -- local, s3, gcs
|
||||
storage_path VARCHAR(500),
|
||||
|
||||
-- Metadata
|
||||
uploaded_by UUID,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for attachments
|
||||
CREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- TRIGGERS FOR TENANT SCHEMA
|
||||
-- ============================================================================
|
||||
|
||||
-- Update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply triggers
|
||||
CREATE TRIGGER update_sat_credentials_updated_at BEFORE UPDATE ON sat_credentials
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_cfdis_updated_at BEFORE UPDATE ON cfdis
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_transactions_updated_at BEFORE UPDATE ON transactions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_contacts_updated_at BEFORE UPDATE ON contacts
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_categories_updated_at BEFORE UPDATE ON categories
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON accounts
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_reports_updated_at BEFORE UPDATE ON reports
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON settings
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_bank_accounts_updated_at BEFORE UPDATE ON bank_accounts
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_budget_items_updated_at BEFORE UPDATE ON budget_items
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ============================================================================
|
||||
-- FOREIGN KEY CONSTRAINTS
|
||||
-- ============================================================================
|
||||
|
||||
-- Add foreign keys after all tables are created
|
||||
ALTER TABLE cfdis ADD CONSTRAINT fk_cfdis_category
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE cfdis ADD CONSTRAINT fk_cfdis_contact
|
||||
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE transactions ADD CONSTRAINT fk_transactions_category
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE transactions ADD CONSTRAINT fk_transactions_account
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE transactions ADD CONSTRAINT fk_transactions_contact
|
||||
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE SET NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON TABLE sat_credentials IS 'Encrypted SAT FIEL credentials for each tenant';
|
||||
COMMENT ON TABLE cfdis IS 'CFDI 4.0 invoices (emitted and received)';
|
||||
COMMENT ON TABLE transactions IS 'Unified financial transactions';
|
||||
COMMENT ON TABLE contacts IS 'Customers and suppliers';
|
||||
COMMENT ON TABLE categories IS 'Transaction categorization';
|
||||
COMMENT ON TABLE accounts IS 'Chart of accounts (catalogo de cuentas)';
|
||||
COMMENT ON TABLE metrics_cache IS 'Pre-computed metrics for dashboard';
|
||||
COMMENT ON TABLE alerts IS 'Financial alerts and notifications';
|
||||
COMMENT ON TABLE reports IS 'Generated financial reports';
|
||||
COMMENT ON TABLE settings IS 'Tenant-specific configuration';
|
||||
521
packages/database/src/seed.ts
Normal file
521
packages/database/src/seed.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Database Seed Script
|
||||
*
|
||||
* Populates the database with initial data:
|
||||
* - Subscription plans
|
||||
* - System settings
|
||||
* - Default super admin user
|
||||
*/
|
||||
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Database configuration
|
||||
interface DatabaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
ssl?: boolean | { rejectUnauthorized: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database configuration from environment
|
||||
*/
|
||||
function getConfig(): DatabaseConfig {
|
||||
return {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
database: process.env.DB_NAME || 'horux_strategy',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
ssl: process.env.DB_SSL === 'true'
|
||||
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using SHA-256 (in production, use bcrypt)
|
||||
* This is a placeholder - actual implementation should use bcrypt
|
||||
*/
|
||||
function hashPassword(password: string): string {
|
||||
// In production, use bcrypt with proper salt rounds
|
||||
// This is just for seeding purposes
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = createHash('sha256').update(password + salt).digest('hex');
|
||||
return `sha256:${salt}:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription plans data
|
||||
*/
|
||||
const PLANS = [
|
||||
{
|
||||
id: 'startup',
|
||||
name: 'Startup',
|
||||
description: 'Para emprendedores y negocios en crecimiento',
|
||||
price_monthly_cents: 49900, // $499 MXN
|
||||
price_yearly_cents: 479900, // $4,799 MXN (20% off)
|
||||
max_users: 2,
|
||||
max_cfdis_monthly: 100,
|
||||
max_storage_mb: 1024, // 1 GB
|
||||
max_api_calls_daily: 500,
|
||||
max_reports_monthly: 5,
|
||||
features: {
|
||||
dashboard: true,
|
||||
cfdi_management: true,
|
||||
basic_reports: true,
|
||||
email_support: true,
|
||||
},
|
||||
has_sat_sync: true,
|
||||
has_bank_sync: false,
|
||||
has_ai_insights: false,
|
||||
has_custom_reports: false,
|
||||
has_api_access: false,
|
||||
has_white_label: false,
|
||||
has_priority_support: false,
|
||||
has_dedicated_account_manager: false,
|
||||
data_retention_months: 12,
|
||||
display_order: 1,
|
||||
is_popular: false,
|
||||
},
|
||||
{
|
||||
id: 'pyme',
|
||||
name: 'PyME',
|
||||
description: 'Para pequenas y medianas empresas',
|
||||
price_monthly_cents: 99900, // $999 MXN
|
||||
price_yearly_cents: 959900, // $9,599 MXN (20% off)
|
||||
max_users: 5,
|
||||
max_cfdis_monthly: 500,
|
||||
max_storage_mb: 5120, // 5 GB
|
||||
max_api_calls_daily: 2000,
|
||||
max_reports_monthly: 20,
|
||||
features: {
|
||||
dashboard: true,
|
||||
cfdi_management: true,
|
||||
basic_reports: true,
|
||||
advanced_reports: true,
|
||||
email_support: true,
|
||||
chat_support: true,
|
||||
bank_reconciliation: true,
|
||||
},
|
||||
has_sat_sync: true,
|
||||
has_bank_sync: true,
|
||||
has_ai_insights: true,
|
||||
has_custom_reports: false,
|
||||
has_api_access: false,
|
||||
has_white_label: false,
|
||||
has_priority_support: false,
|
||||
has_dedicated_account_manager: false,
|
||||
data_retention_months: 24,
|
||||
display_order: 2,
|
||||
is_popular: true,
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
description: 'Para empresas en expansion con necesidades avanzadas',
|
||||
price_monthly_cents: 249900, // $2,499 MXN
|
||||
price_yearly_cents: 2399900, // $23,999 MXN (20% off)
|
||||
max_users: 15,
|
||||
max_cfdis_monthly: 2000,
|
||||
max_storage_mb: 20480, // 20 GB
|
||||
max_api_calls_daily: 10000,
|
||||
max_reports_monthly: 100,
|
||||
features: {
|
||||
dashboard: true,
|
||||
cfdi_management: true,
|
||||
basic_reports: true,
|
||||
advanced_reports: true,
|
||||
custom_reports: true,
|
||||
email_support: true,
|
||||
chat_support: true,
|
||||
phone_support: true,
|
||||
bank_reconciliation: true,
|
||||
multi_currency: true,
|
||||
budget_planning: true,
|
||||
api_access: true,
|
||||
},
|
||||
has_sat_sync: true,
|
||||
has_bank_sync: true,
|
||||
has_ai_insights: true,
|
||||
has_custom_reports: true,
|
||||
has_api_access: true,
|
||||
has_white_label: false,
|
||||
has_priority_support: true,
|
||||
has_dedicated_account_manager: false,
|
||||
data_retention_months: 60,
|
||||
display_order: 3,
|
||||
is_popular: false,
|
||||
},
|
||||
{
|
||||
id: 'corporativo',
|
||||
name: 'Corporativo',
|
||||
description: 'Solucion completa para grandes corporaciones',
|
||||
price_monthly_cents: 499900, // $4,999 MXN
|
||||
price_yearly_cents: 4799900, // $47,999 MXN (20% off)
|
||||
max_users: 50,
|
||||
max_cfdis_monthly: 10000,
|
||||
max_storage_mb: 102400, // 100 GB
|
||||
max_api_calls_daily: 50000,
|
||||
max_reports_monthly: -1, // Unlimited
|
||||
features: {
|
||||
dashboard: true,
|
||||
cfdi_management: true,
|
||||
basic_reports: true,
|
||||
advanced_reports: true,
|
||||
custom_reports: true,
|
||||
email_support: true,
|
||||
chat_support: true,
|
||||
phone_support: true,
|
||||
bank_reconciliation: true,
|
||||
multi_currency: true,
|
||||
budget_planning: true,
|
||||
api_access: true,
|
||||
white_label: true,
|
||||
sso: true,
|
||||
audit_logs: true,
|
||||
custom_integrations: true,
|
||||
dedicated_infrastructure: true,
|
||||
},
|
||||
has_sat_sync: true,
|
||||
has_bank_sync: true,
|
||||
has_ai_insights: true,
|
||||
has_custom_reports: true,
|
||||
has_api_access: true,
|
||||
has_white_label: true,
|
||||
has_priority_support: true,
|
||||
has_dedicated_account_manager: true,
|
||||
data_retention_months: 120, // 10 years
|
||||
display_order: 4,
|
||||
is_popular: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* System settings data
|
||||
*/
|
||||
const SYSTEM_SETTINGS = [
|
||||
// General
|
||||
{ key: 'app_name', value: 'Horux Strategy', category: 'general', value_type: 'string' },
|
||||
{ key: 'app_version', value: '0.1.0', category: 'general', value_type: 'string' },
|
||||
{ key: 'app_environment', value: 'development', category: 'general', value_type: 'string' },
|
||||
{ key: 'default_timezone', value: 'America/Mexico_City', category: 'general', value_type: 'string' },
|
||||
{ key: 'default_locale', value: 'es-MX', category: 'general', value_type: 'string' },
|
||||
{ key: 'default_currency', value: 'MXN', category: 'general', value_type: 'string' },
|
||||
|
||||
// Authentication
|
||||
{ key: 'session_duration_hours', value: '24', category: 'auth', value_type: 'integer' },
|
||||
{ key: 'refresh_token_duration_days', value: '30', category: 'auth', value_type: 'integer' },
|
||||
{ key: 'max_login_attempts', value: '5', category: 'auth', value_type: 'integer' },
|
||||
{ key: 'lockout_duration_minutes', value: '30', category: 'auth', value_type: 'integer' },
|
||||
{ key: 'password_min_length', value: '8', category: 'auth', value_type: 'integer' },
|
||||
{ key: 'require_2fa_for_admin', value: 'true', category: 'auth', value_type: 'boolean' },
|
||||
|
||||
// Email
|
||||
{ key: 'email_from_name', value: 'Horux Strategy', category: 'email', value_type: 'string' },
|
||||
{ key: 'email_from_address', value: 'noreply@horuxstrategy.com', category: 'email', value_type: 'string' },
|
||||
|
||||
// SAT Integration
|
||||
{ key: 'sat_api_base_url', value: 'https://cfdidescargamasiva.clouda.sat.gob.mx', category: 'sat', value_type: 'string' },
|
||||
{ key: 'sat_sync_batch_size', value: '100', category: 'sat', value_type: 'integer' },
|
||||
{ key: 'sat_sync_max_retries', value: '3', category: 'sat', value_type: 'integer' },
|
||||
|
||||
// Jobs
|
||||
{ key: 'job_cleanup_days', value: '30', category: 'jobs', value_type: 'integer' },
|
||||
{ key: 'job_max_concurrent', value: '5', category: 'jobs', value_type: 'integer' },
|
||||
|
||||
// Maintenance
|
||||
{ key: 'maintenance_mode', value: 'false', category: 'maintenance', value_type: 'boolean' },
|
||||
{ key: 'maintenance_message', value: '', category: 'maintenance', value_type: 'string' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Seed plans
|
||||
*/
|
||||
async function seedPlans(client: PoolClient): Promise<void> {
|
||||
console.log('Seeding subscription plans...');
|
||||
|
||||
for (const plan of PLANS) {
|
||||
await client.query(
|
||||
`INSERT INTO plans (
|
||||
id, name, description,
|
||||
price_monthly_cents, price_yearly_cents,
|
||||
max_users, max_cfdis_monthly, max_storage_mb, max_api_calls_daily, max_reports_monthly,
|
||||
features,
|
||||
has_sat_sync, has_bank_sync, has_ai_insights, has_custom_reports,
|
||||
has_api_access, has_white_label, has_priority_support, has_dedicated_account_manager,
|
||||
data_retention_months, display_order, is_popular
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
price_monthly_cents = EXCLUDED.price_monthly_cents,
|
||||
price_yearly_cents = EXCLUDED.price_yearly_cents,
|
||||
max_users = EXCLUDED.max_users,
|
||||
max_cfdis_monthly = EXCLUDED.max_cfdis_monthly,
|
||||
max_storage_mb = EXCLUDED.max_storage_mb,
|
||||
max_api_calls_daily = EXCLUDED.max_api_calls_daily,
|
||||
max_reports_monthly = EXCLUDED.max_reports_monthly,
|
||||
features = EXCLUDED.features,
|
||||
has_sat_sync = EXCLUDED.has_sat_sync,
|
||||
has_bank_sync = EXCLUDED.has_bank_sync,
|
||||
has_ai_insights = EXCLUDED.has_ai_insights,
|
||||
has_custom_reports = EXCLUDED.has_custom_reports,
|
||||
has_api_access = EXCLUDED.has_api_access,
|
||||
has_white_label = EXCLUDED.has_white_label,
|
||||
has_priority_support = EXCLUDED.has_priority_support,
|
||||
has_dedicated_account_manager = EXCLUDED.has_dedicated_account_manager,
|
||||
data_retention_months = EXCLUDED.data_retention_months,
|
||||
display_order = EXCLUDED.display_order,
|
||||
is_popular = EXCLUDED.is_popular,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
plan.id,
|
||||
plan.name,
|
||||
plan.description,
|
||||
plan.price_monthly_cents,
|
||||
plan.price_yearly_cents,
|
||||
plan.max_users,
|
||||
plan.max_cfdis_monthly,
|
||||
plan.max_storage_mb,
|
||||
plan.max_api_calls_daily,
|
||||
plan.max_reports_monthly,
|
||||
JSON.stringify(plan.features),
|
||||
plan.has_sat_sync,
|
||||
plan.has_bank_sync,
|
||||
plan.has_ai_insights,
|
||||
plan.has_custom_reports,
|
||||
plan.has_api_access,
|
||||
plan.has_white_label,
|
||||
plan.has_priority_support,
|
||||
plan.has_dedicated_account_manager,
|
||||
plan.data_retention_months,
|
||||
plan.display_order,
|
||||
plan.is_popular,
|
||||
]
|
||||
);
|
||||
console.log(` - Plan: ${plan.name} ($${(plan.price_monthly_cents / 100).toFixed(2)} MXN/mes)`);
|
||||
}
|
||||
|
||||
console.log(` Total: ${PLANS.length} plans seeded\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed system settings
|
||||
*/
|
||||
async function seedSystemSettings(client: PoolClient): Promise<void> {
|
||||
console.log('Seeding system settings...');
|
||||
|
||||
for (const setting of SYSTEM_SETTINGS) {
|
||||
await client.query(
|
||||
`INSERT INTO system_settings (key, value, value_type, category)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
value_type = EXCLUDED.value_type,
|
||||
category = EXCLUDED.category,
|
||||
updated_at = NOW()`,
|
||||
[setting.key, setting.value, setting.value_type, setting.category]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(` Total: ${SYSTEM_SETTINGS.length} settings seeded\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed default super admin user
|
||||
*/
|
||||
async function seedSuperAdmin(client: PoolClient): Promise<void> {
|
||||
console.log('Seeding super admin user...');
|
||||
|
||||
const email = process.env.SUPER_ADMIN_EMAIL || 'admin@horuxstrategy.com';
|
||||
const password = process.env.SUPER_ADMIN_PASSWORD || 'HoruxAdmin2024!';
|
||||
|
||||
// Check if user already exists
|
||||
const existing = await client.query(
|
||||
'SELECT id FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
console.log(` - Super admin already exists: ${email}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create super admin
|
||||
const passwordHash = hashPassword(password);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO users (
|
||||
email, password_hash, first_name, last_name,
|
||||
default_role, is_active, is_email_verified, email_verified_at
|
||||
) VALUES (
|
||||
$1, $2, 'Super', 'Admin',
|
||||
'super_admin', true, true, NOW()
|
||||
)`,
|
||||
[email, passwordHash]
|
||||
);
|
||||
|
||||
console.log(` - Created super admin: ${email}`);
|
||||
console.log(` - Default password: ${password}`);
|
||||
console.log(' - IMPORTANT: Change the password immediately!\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed demo tenant (optional)
|
||||
*/
|
||||
async function seedDemoTenant(client: PoolClient): Promise<void> {
|
||||
if (process.env.SEED_DEMO_TENANT !== 'true') {
|
||||
console.log('Skipping demo tenant (set SEED_DEMO_TENANT=true to enable)\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Seeding demo tenant...');
|
||||
|
||||
// Get super admin user
|
||||
const adminResult = await client.query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE default_role = 'super_admin' LIMIT 1"
|
||||
);
|
||||
|
||||
if (adminResult.rows.length === 0) {
|
||||
console.log(' - No super admin found, skipping demo tenant\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const adminId = adminResult.rows[0].id;
|
||||
|
||||
// Check if demo tenant exists
|
||||
const existingTenant = await client.query(
|
||||
"SELECT id FROM tenants WHERE slug = 'demo-company'"
|
||||
);
|
||||
|
||||
if (existingTenant.rows.length > 0) {
|
||||
console.log(' - Demo tenant already exists\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create demo tenant
|
||||
const tenantResult = await client.query<{ id: string }>(
|
||||
`INSERT INTO tenants (
|
||||
name, slug, schema_name, rfc, razon_social,
|
||||
email, phone, owner_id, plan_id, status, settings
|
||||
) VALUES (
|
||||
'Empresa Demo S.A. de C.V.',
|
||||
'demo-company',
|
||||
'tenant_demo',
|
||||
'XAXX010101000',
|
||||
'Empresa Demo S.A. de C.V.',
|
||||
'demo@horuxstrategy.com',
|
||||
'+52 55 1234 5678',
|
||||
$1,
|
||||
'pyme',
|
||||
'active',
|
||||
$2
|
||||
) RETURNING id`,
|
||||
[
|
||||
adminId,
|
||||
JSON.stringify({
|
||||
timezone: 'America/Mexico_City',
|
||||
currency: 'MXN',
|
||||
language: 'es-MX',
|
||||
fiscalYearStart: 1,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
const tenantId = tenantResult.rows[0].id;
|
||||
|
||||
// Add admin to tenant
|
||||
await client.query(
|
||||
`INSERT INTO user_tenants (user_id, tenant_id, role, is_active, accepted_at)
|
||||
VALUES ($1, $2, 'owner', true, NOW())`,
|
||||
[adminId, tenantId]
|
||||
);
|
||||
|
||||
// Create subscription
|
||||
const trialEnds = new Date();
|
||||
trialEnds.setDate(trialEnds.getDate() + 14); // 14-day trial
|
||||
|
||||
const periodEnd = new Date();
|
||||
periodEnd.setMonth(periodEnd.getMonth() + 1);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO subscriptions (
|
||||
tenant_id, plan_id, status, billing_cycle,
|
||||
trial_ends_at, current_period_end, price_cents
|
||||
) VALUES (
|
||||
$1, 'pyme', 'trial', 'monthly',
|
||||
$2, $3, 99900
|
||||
)`,
|
||||
[tenantId, trialEnds, periodEnd]
|
||||
);
|
||||
|
||||
console.log(` - Created demo tenant: Empresa Demo S.A. de C.V.`);
|
||||
console.log(` - Tenant ID: ${tenantId}`);
|
||||
console.log(` - Schema: tenant_demo`);
|
||||
console.log(' - Note: Tenant schema needs to be created separately using createTenantSchema()\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main seed function
|
||||
*/
|
||||
async function seed(): Promise<void> {
|
||||
const config = getConfig();
|
||||
|
||||
console.log('\nHorux Strategy - Database Seed');
|
||||
console.log('==============================');
|
||||
console.log(`Database: ${config.database}@${config.host}:${config.port}\n`);
|
||||
|
||||
const pool = new Pool(config);
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Run seeders
|
||||
await seedPlans(client);
|
||||
await seedSystemSettings(client);
|
||||
await seedSuperAdmin(client);
|
||||
await seedDemoTenant(client);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
console.log('Seed completed successfully!\n');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Seed failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
seed().catch(console.error);
|
||||
|
||||
// Export for programmatic use
|
||||
export {
|
||||
seed,
|
||||
seedPlans,
|
||||
seedSystemSettings,
|
||||
seedSuperAdmin,
|
||||
seedDemoTenant,
|
||||
PLANS,
|
||||
SYSTEM_SETTINGS,
|
||||
};
|
||||
619
packages/database/src/tenant.ts
Normal file
619
packages/database/src/tenant.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* Tenant Schema Management
|
||||
*
|
||||
* Functions for creating, deleting, and managing tenant schemas.
|
||||
* Each tenant gets their own PostgreSQL schema with isolated data.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { DatabaseConnection, TenantContext } from './connection.js';
|
||||
import { PoolClient } from 'pg';
|
||||
|
||||
// Get directory path for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Tenant creation options
|
||||
export interface CreateTenantOptions {
|
||||
tenantId: string;
|
||||
companyName: string;
|
||||
ownerId: string;
|
||||
planId?: string;
|
||||
settings?: TenantSettings;
|
||||
}
|
||||
|
||||
// Tenant settings
|
||||
export interface TenantSettings {
|
||||
timezone?: string;
|
||||
currency?: string;
|
||||
language?: string;
|
||||
fiscalYearStart?: number; // Month (1-12)
|
||||
dateFormat?: string;
|
||||
numberFormat?: string;
|
||||
}
|
||||
|
||||
// Default tenant settings for Mexican companies
|
||||
const DEFAULT_TENANT_SETTINGS: TenantSettings = {
|
||||
timezone: 'America/Mexico_City',
|
||||
currency: 'MXN',
|
||||
language: 'es-MX',
|
||||
fiscalYearStart: 1, // January
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
numberFormat: 'es-MX',
|
||||
};
|
||||
|
||||
// Tenant info
|
||||
export interface TenantInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
schemaName: string;
|
||||
status: TenantStatus;
|
||||
planId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Tenant status
|
||||
export type TenantStatus = 'active' | 'suspended' | 'pending' | 'deleted';
|
||||
|
||||
/**
|
||||
* Generate schema name from tenant ID
|
||||
*/
|
||||
export function getSchemaName(tenantId: string): string {
|
||||
// Validate tenant ID format (UUID or alphanumeric)
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(tenantId)) {
|
||||
throw new Error(`Invalid tenant ID format: ${tenantId}`);
|
||||
}
|
||||
|
||||
// Replace hyphens with underscores for valid schema name
|
||||
const safeTenantId = tenantId.replace(/-/g, '_');
|
||||
return `tenant_${safeTenantId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tenant context
|
||||
*/
|
||||
export function createTenantContext(tenantId: string, userId?: string): TenantContext {
|
||||
return {
|
||||
tenantId,
|
||||
schemaName: getSchemaName(tenantId),
|
||||
userId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the tenant schema migration SQL
|
||||
*/
|
||||
function getTenantSchemaSql(): string {
|
||||
const migrationPath = join(__dirname, 'migrations', '002_tenant_schema.sql');
|
||||
return readFileSync(migrationPath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tenant schema with all required tables
|
||||
*/
|
||||
export async function createTenantSchema(
|
||||
db: DatabaseConnection,
|
||||
options: CreateTenantOptions
|
||||
): Promise<TenantInfo> {
|
||||
const { tenantId, companyName, ownerId, planId, settings } = options;
|
||||
const schemaName = getSchemaName(tenantId);
|
||||
const slug = companyName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
// Merge settings with defaults
|
||||
const finalSettings = { ...DEFAULT_TENANT_SETTINGS, ...settings };
|
||||
|
||||
// Check if schema already exists
|
||||
const exists = await db.schemaExists(schemaName);
|
||||
if (exists) {
|
||||
throw new Error(`Tenant schema already exists: ${schemaName}`);
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
const result = await db.transaction<TenantInfo>(async (client: PoolClient) => {
|
||||
// 1. Create tenant record in public schema
|
||||
const tenantResult = await client.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
schema_name: string;
|
||||
status: TenantStatus;
|
||||
plan_id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}>(
|
||||
`INSERT INTO public.tenants (id, name, slug, schema_name, owner_id, plan_id, status, settings)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7)
|
||||
RETURNING id, name, slug, schema_name, status, plan_id, created_at, updated_at`,
|
||||
[
|
||||
tenantId,
|
||||
companyName,
|
||||
slug,
|
||||
schemaName,
|
||||
ownerId,
|
||||
planId || 'startup',
|
||||
JSON.stringify(finalSettings),
|
||||
]
|
||||
);
|
||||
|
||||
const tenant = tenantResult.rows[0];
|
||||
|
||||
// 2. Create the schema
|
||||
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
|
||||
|
||||
// 3. Set search path to the new schema
|
||||
await client.query(`SET search_path TO "${schemaName}", public`);
|
||||
|
||||
// 4. Execute tenant schema migration
|
||||
const tenantSchemaSql = getTenantSchemaSql();
|
||||
|
||||
// Replace schema placeholder with actual schema name
|
||||
const schemaSql = tenantSchemaSql.replace(/\$\{SCHEMA_NAME\}/g, schemaName);
|
||||
|
||||
await client.query(schemaSql);
|
||||
|
||||
// 5. Insert default settings
|
||||
await client.query(
|
||||
`INSERT INTO settings (key, value, category)
|
||||
VALUES
|
||||
('timezone', $1, 'general'),
|
||||
('currency', $2, 'general'),
|
||||
('language', $3, 'general'),
|
||||
('fiscal_year_start', $4, 'accounting'),
|
||||
('date_format', $5, 'display'),
|
||||
('number_format', $6, 'display')`,
|
||||
[
|
||||
finalSettings.timezone,
|
||||
finalSettings.currency,
|
||||
finalSettings.language,
|
||||
String(finalSettings.fiscalYearStart),
|
||||
finalSettings.dateFormat,
|
||||
finalSettings.numberFormat,
|
||||
]
|
||||
);
|
||||
|
||||
// 6. Insert default categories
|
||||
await insertDefaultCategories(client);
|
||||
|
||||
// 7. Insert default accounts (chart of accounts)
|
||||
await insertDefaultAccounts(client);
|
||||
|
||||
// 8. Log the creation in audit log
|
||||
await client.query(
|
||||
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
|
||||
VALUES ($1, $2, 'tenant.created', 'tenant', $1, $3)`,
|
||||
[tenantId, ownerId, JSON.stringify({ company_name: companyName, schema_name: schemaName })]
|
||||
);
|
||||
|
||||
// Reset search path
|
||||
await client.query('RESET search_path');
|
||||
|
||||
return {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
schemaName: tenant.schema_name,
|
||||
status: tenant.status,
|
||||
planId: tenant.plan_id,
|
||||
createdAt: tenant.created_at,
|
||||
updatedAt: tenant.updated_at,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert default expense/income categories
|
||||
*/
|
||||
async function insertDefaultCategories(client: PoolClient): Promise<void> {
|
||||
// Mexican-specific expense categories
|
||||
const categories = [
|
||||
// Income categories
|
||||
{ code: 'ING-001', name: 'Ventas de productos', type: 'income', sat_key: '84111506' },
|
||||
{ code: 'ING-002', name: 'Servicios profesionales', type: 'income', sat_key: '80111601' },
|
||||
{ code: 'ING-003', name: 'Comisiones', type: 'income', sat_key: '84111502' },
|
||||
{ code: 'ING-004', name: 'Intereses ganados', type: 'income', sat_key: '84111503' },
|
||||
{ code: 'ING-005', name: 'Otros ingresos', type: 'income', sat_key: '84111599' },
|
||||
|
||||
// Expense categories - Operating
|
||||
{ code: 'GAS-001', name: 'Sueldos y salarios', type: 'expense', sat_key: '80111501' },
|
||||
{ code: 'GAS-002', name: 'Servicios profesionales', type: 'expense', sat_key: '80111601' },
|
||||
{ code: 'GAS-003', name: 'Arrendamiento', type: 'expense', sat_key: '80131501' },
|
||||
{ code: 'GAS-004', name: 'Servicios de luz', type: 'expense', sat_key: '83101801' },
|
||||
{ code: 'GAS-005', name: 'Servicios de agua', type: 'expense', sat_key: '83101802' },
|
||||
{ code: 'GAS-006', name: 'Telecomunicaciones', type: 'expense', sat_key: '83111501' },
|
||||
{ code: 'GAS-007', name: 'Combustibles', type: 'expense', sat_key: '15101506' },
|
||||
{ code: 'GAS-008', name: 'Mantenimiento', type: 'expense', sat_key: '72101507' },
|
||||
{ code: 'GAS-009', name: 'Papeleria y utiles', type: 'expense', sat_key: '44121600' },
|
||||
{ code: 'GAS-010', name: 'Seguros y fianzas', type: 'expense', sat_key: '84131501' },
|
||||
|
||||
// Expense categories - Administrative
|
||||
{ code: 'ADM-001', name: 'Honorarios contables', type: 'expense', sat_key: '80111604' },
|
||||
{ code: 'ADM-002', name: 'Honorarios legales', type: 'expense', sat_key: '80111607' },
|
||||
{ code: 'ADM-003', name: 'Capacitacion', type: 'expense', sat_key: '86101701' },
|
||||
{ code: 'ADM-004', name: 'Publicidad', type: 'expense', sat_key: '80141600' },
|
||||
{ code: 'ADM-005', name: 'Viajes y viaticos', type: 'expense', sat_key: '90101800' },
|
||||
|
||||
// Expense categories - Financial
|
||||
{ code: 'FIN-001', name: 'Comisiones bancarias', type: 'expense', sat_key: '84111502' },
|
||||
{ code: 'FIN-002', name: 'Intereses pagados', type: 'expense', sat_key: '84111503' },
|
||||
|
||||
// Cost categories
|
||||
{ code: 'COS-001', name: 'Costo de ventas', type: 'cost', sat_key: '84111506' },
|
||||
{ code: 'COS-002', name: 'Materia prima', type: 'cost', sat_key: '11000000' },
|
||||
{ code: 'COS-003', name: 'Mano de obra directa', type: 'cost', sat_key: '80111501' },
|
||||
];
|
||||
|
||||
for (const cat of categories) {
|
||||
await client.query(
|
||||
`INSERT INTO categories (code, name, type, sat_key, is_system)
|
||||
VALUES ($1, $2, $3, $4, true)`,
|
||||
[cat.code, cat.name, cat.type, cat.sat_key]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert default chart of accounts (Mexican CUCA standard)
|
||||
*/
|
||||
async function insertDefaultAccounts(client: PoolClient): Promise<void> {
|
||||
// Mexican Chart of Accounts (simplified)
|
||||
const accounts = [
|
||||
// Assets (1xxx)
|
||||
{ code: '1000', name: 'Activo', type: 'asset', parent: null },
|
||||
{ code: '1100', name: 'Activo Circulante', type: 'asset', parent: '1000' },
|
||||
{ code: '1101', name: 'Caja', type: 'asset', parent: '1100' },
|
||||
{ code: '1102', name: 'Bancos', type: 'asset', parent: '1100' },
|
||||
{ code: '1103', name: 'Inversiones temporales', type: 'asset', parent: '1100' },
|
||||
{ code: '1110', name: 'Clientes', type: 'asset', parent: '1100' },
|
||||
{ code: '1120', name: 'Documentos por cobrar', type: 'asset', parent: '1100' },
|
||||
{ code: '1130', name: 'Deudores diversos', type: 'asset', parent: '1100' },
|
||||
{ code: '1140', name: 'Inventarios', type: 'asset', parent: '1100' },
|
||||
{ code: '1150', name: 'Anticipo a proveedores', type: 'asset', parent: '1100' },
|
||||
{ code: '1160', name: 'IVA acreditable', type: 'asset', parent: '1100' },
|
||||
{ code: '1200', name: 'Activo Fijo', type: 'asset', parent: '1000' },
|
||||
{ code: '1201', name: 'Terrenos', type: 'asset', parent: '1200' },
|
||||
{ code: '1202', name: 'Edificios', type: 'asset', parent: '1200' },
|
||||
{ code: '1203', name: 'Maquinaria y equipo', type: 'asset', parent: '1200' },
|
||||
{ code: '1204', name: 'Equipo de transporte', type: 'asset', parent: '1200' },
|
||||
{ code: '1205', name: 'Equipo de computo', type: 'asset', parent: '1200' },
|
||||
{ code: '1206', name: 'Mobiliario y equipo', type: 'asset', parent: '1200' },
|
||||
{ code: '1210', name: 'Depreciacion acumulada', type: 'asset', parent: '1200' },
|
||||
|
||||
// Liabilities (2xxx)
|
||||
{ code: '2000', name: 'Pasivo', type: 'liability', parent: null },
|
||||
{ code: '2100', name: 'Pasivo Corto Plazo', type: 'liability', parent: '2000' },
|
||||
{ code: '2101', name: 'Proveedores', type: 'liability', parent: '2100' },
|
||||
{ code: '2102', name: 'Acreedores diversos', type: 'liability', parent: '2100' },
|
||||
{ code: '2103', name: 'Documentos por pagar', type: 'liability', parent: '2100' },
|
||||
{ code: '2110', name: 'Impuestos por pagar', type: 'liability', parent: '2100' },
|
||||
{ code: '2111', name: 'IVA por pagar', type: 'liability', parent: '2110' },
|
||||
{ code: '2112', name: 'ISR por pagar', type: 'liability', parent: '2110' },
|
||||
{ code: '2113', name: 'Retenciones por pagar', type: 'liability', parent: '2110' },
|
||||
{ code: '2120', name: 'Anticipo de clientes', type: 'liability', parent: '2100' },
|
||||
{ code: '2200', name: 'Pasivo Largo Plazo', type: 'liability', parent: '2000' },
|
||||
{ code: '2201', name: 'Prestamos bancarios LP', type: 'liability', parent: '2200' },
|
||||
{ code: '2202', name: 'Hipotecas por pagar', type: 'liability', parent: '2200' },
|
||||
|
||||
// Equity (3xxx)
|
||||
{ code: '3000', name: 'Capital Contable', type: 'equity', parent: null },
|
||||
{ code: '3100', name: 'Capital Social', type: 'equity', parent: '3000' },
|
||||
{ code: '3200', name: 'Reserva legal', type: 'equity', parent: '3000' },
|
||||
{ code: '3300', name: 'Resultados acumulados', type: 'equity', parent: '3000' },
|
||||
{ code: '3400', name: 'Resultado del ejercicio', type: 'equity', parent: '3000' },
|
||||
|
||||
// Revenue (4xxx)
|
||||
{ code: '4000', name: 'Ingresos', type: 'revenue', parent: null },
|
||||
{ code: '4100', name: 'Ventas', type: 'revenue', parent: '4000' },
|
||||
{ code: '4101', name: 'Ventas nacionales', type: 'revenue', parent: '4100' },
|
||||
{ code: '4102', name: 'Ventas de exportacion', type: 'revenue', parent: '4100' },
|
||||
{ code: '4200', name: 'Productos financieros', type: 'revenue', parent: '4000' },
|
||||
{ code: '4300', name: 'Otros ingresos', type: 'revenue', parent: '4000' },
|
||||
|
||||
// Expenses (5xxx-6xxx)
|
||||
{ code: '5000', name: 'Costo de Ventas', type: 'expense', parent: null },
|
||||
{ code: '5100', name: 'Costo de lo vendido', type: 'expense', parent: '5000' },
|
||||
{ code: '6000', name: 'Gastos de Operacion', type: 'expense', parent: null },
|
||||
{ code: '6100', name: 'Gastos de venta', type: 'expense', parent: '6000' },
|
||||
{ code: '6200', name: 'Gastos de administracion', type: 'expense', parent: '6000' },
|
||||
{ code: '6300', name: 'Gastos financieros', type: 'expense', parent: '6000' },
|
||||
{ code: '6400', name: 'Otros gastos', type: 'expense', parent: '6000' },
|
||||
];
|
||||
|
||||
// First pass: insert accounts without parent references
|
||||
for (const account of accounts) {
|
||||
await client.query(
|
||||
`INSERT INTO accounts (code, name, type, is_system)
|
||||
VALUES ($1, $2, $3, true)`,
|
||||
[account.code, account.name, account.type]
|
||||
);
|
||||
}
|
||||
|
||||
// Second pass: update parent references
|
||||
for (const account of accounts) {
|
||||
if (account.parent) {
|
||||
await client.query(
|
||||
`UPDATE accounts SET parent_id = (SELECT id FROM accounts WHERE code = $1)
|
||||
WHERE code = $2`,
|
||||
[account.parent, account.code]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tenant schema and all its data
|
||||
* WARNING: This is destructive and cannot be undone!
|
||||
*/
|
||||
export async function deleteTenantSchema(
|
||||
db: DatabaseConnection,
|
||||
tenantId: string,
|
||||
deletedBy: string,
|
||||
hardDelete: boolean = false
|
||||
): Promise<void> {
|
||||
const schemaName = getSchemaName(tenantId);
|
||||
|
||||
await db.transaction(async (client: PoolClient) => {
|
||||
// Verify tenant exists
|
||||
const tenantResult = await client.query<{ status: TenantStatus }>(
|
||||
'SELECT status FROM public.tenants WHERE id = $1',
|
||||
[tenantId]
|
||||
);
|
||||
|
||||
if (tenantResult.rows.length === 0) {
|
||||
throw new Error(`Tenant not found: ${tenantId}`);
|
||||
}
|
||||
|
||||
if (hardDelete) {
|
||||
// Drop the schema and all its objects
|
||||
await client.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
|
||||
|
||||
// Delete tenant record
|
||||
await client.query('DELETE FROM public.tenants WHERE id = $1', [tenantId]);
|
||||
} else {
|
||||
// Soft delete: mark as deleted but keep data
|
||||
await client.query(
|
||||
`UPDATE public.tenants SET status = 'deleted', updated_at = NOW() WHERE id = $1`,
|
||||
[tenantId]
|
||||
);
|
||||
}
|
||||
|
||||
// Log the deletion
|
||||
await client.query(
|
||||
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
|
||||
VALUES ($1, $2, 'tenant.deleted', 'tenant', $1, $3)`,
|
||||
[tenantId, deletedBy, JSON.stringify({ hard_delete: hardDelete, schema_name: schemaName })]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend a tenant (deactivate but don't delete)
|
||||
*/
|
||||
export async function suspendTenant(
|
||||
db: DatabaseConnection,
|
||||
tenantId: string,
|
||||
suspendedBy: string,
|
||||
reason?: string
|
||||
): Promise<void> {
|
||||
await db.transaction(async (client: PoolClient) => {
|
||||
await client.query(
|
||||
`UPDATE public.tenants SET status = 'suspended', updated_at = NOW() WHERE id = $1`,
|
||||
[tenantId]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
|
||||
VALUES ($1, $2, 'tenant.suspended', 'tenant', $1, $3)`,
|
||||
[tenantId, suspendedBy, JSON.stringify({ reason })]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a suspended tenant
|
||||
*/
|
||||
export async function reactivateTenant(
|
||||
db: DatabaseConnection,
|
||||
tenantId: string,
|
||||
reactivatedBy: string
|
||||
): Promise<void> {
|
||||
await db.transaction(async (client: PoolClient) => {
|
||||
await client.query(
|
||||
`UPDATE public.tenants SET status = 'active', updated_at = NOW() WHERE id = $1`,
|
||||
[tenantId]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
|
||||
VALUES ($1, $2, 'tenant.reactivated', 'tenant', $1, $3)`,
|
||||
[tenantId, reactivatedBy, JSON.stringify({})]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant information
|
||||
*/
|
||||
export async function getTenant(
|
||||
db: DatabaseConnection,
|
||||
tenantId: string
|
||||
): Promise<TenantInfo | null> {
|
||||
const result = await db.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
schema_name: string;
|
||||
status: TenantStatus;
|
||||
plan_id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}>(
|
||||
`SELECT id, name, slug, schema_name, status, plan_id, created_at, updated_at
|
||||
FROM public.tenants WHERE id = $1`,
|
||||
[tenantId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tenant = result.rows[0];
|
||||
return {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
schemaName: tenant.schema_name,
|
||||
status: tenant.status,
|
||||
planId: tenant.plan_id,
|
||||
createdAt: tenant.created_at,
|
||||
updatedAt: tenant.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tenants
|
||||
*/
|
||||
export async function listTenants(
|
||||
db: DatabaseConnection,
|
||||
status?: TenantStatus
|
||||
): Promise<TenantInfo[]> {
|
||||
let query = `
|
||||
SELECT id, name, slug, schema_name, status, plan_id, created_at, updated_at
|
||||
FROM public.tenants
|
||||
`;
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (status) {
|
||||
query += ' WHERE status = $1';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
const result = await db.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
schema_name: string;
|
||||
status: TenantStatus;
|
||||
plan_id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}>(query, params);
|
||||
|
||||
return result.rows.map(tenant => ({
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
schemaName: tenant.schema_name,
|
||||
status: tenant.status,
|
||||
planId: tenant.plan_id,
|
||||
createdAt: tenant.created_at,
|
||||
updatedAt: tenant.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tenant settings
|
||||
*/
|
||||
export async function updateTenantSettings(
|
||||
db: DatabaseConnection,
|
||||
tenantId: string,
|
||||
settings: Partial<TenantSettings>,
|
||||
updatedBy: string
|
||||
): Promise<void> {
|
||||
const tenant = await getTenant(db, tenantId);
|
||||
if (!tenant) {
|
||||
throw new Error(`Tenant not found: ${tenantId}`);
|
||||
}
|
||||
|
||||
await db.transaction(async (client: PoolClient) => {
|
||||
// Update settings in tenant schema
|
||||
await client.query(`SET search_path TO "${tenant.schemaName}", public`);
|
||||
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (value !== undefined) {
|
||||
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||||
await client.query(
|
||||
`INSERT INTO settings (key, value, category)
|
||||
VALUES ($1, $2, 'general')
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||
[snakeKey, String(value)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('RESET search_path');
|
||||
|
||||
// Log the update
|
||||
await client.query(
|
||||
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
|
||||
VALUES ($1, $2, 'tenant.settings_updated', 'tenant', $1, $3)`,
|
||||
[tenantId, updatedBy, JSON.stringify(settings)]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant by slug
|
||||
*/
|
||||
export async function getTenantBySlug(
|
||||
db: DatabaseConnection,
|
||||
slug: string
|
||||
): Promise<TenantInfo | null> {
|
||||
const result = await db.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
schema_name: string;
|
||||
status: TenantStatus;
|
||||
plan_id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}>(
|
||||
`SELECT id, name, slug, schema_name, status, plan_id, created_at, updated_at
|
||||
FROM public.tenants WHERE slug = $1`,
|
||||
[slug]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tenant = result.rows[0];
|
||||
return {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
schemaName: tenant.schema_name,
|
||||
status: tenant.status,
|
||||
planId: tenant.plan_id,
|
||||
createdAt: tenant.created_at,
|
||||
updatedAt: tenant.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tenant access
|
||||
* Returns true if the tenant exists and is active
|
||||
*/
|
||||
export async function validateTenantAccess(
|
||||
db: DatabaseConnection,
|
||||
tenantId: string
|
||||
): Promise<boolean> {
|
||||
const result = await db.query<{ status: TenantStatus }>(
|
||||
'SELECT status FROM public.tenants WHERE id = $1',
|
||||
[tenantId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.rows[0].status === 'active';
|
||||
}
|
||||
Reference in New Issue
Block a user