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:
2026-01-31 11:05:24 +00:00
parent c1321c3f0c
commit a9b1994c48
110 changed files with 40788 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
{
"name": "@horux/database",
"version": "0.1.0",
"private": true,
"description": "Database schemas and migrations for Horux Strategy",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"migrate": "node dist/migrate.js",
"migrate:dev": "tsx src/migrate.ts",
"seed": "tsx src/seed.ts",
"studio": "echo 'DB Studio not configured'",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist node_modules"
},
"dependencies": {
"@horux/shared": "workspace:*",
"pg": "^8.11.3",
"dotenv": "^16.4.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/pg": "^8.10.9",
"eslint": "^8.56.0",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}

View 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 };

View 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;
}

View 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,
};

View 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';

View 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';

View 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,
};

View 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';
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}