[TASK-2026-02-05-EJECUCION-REMEDIATION-ERP-CORE] feat: Complete Sprint 0-4 data modeling remediation

Sprint 0: Updated inventories (MASTER/BACKEND/DATABASE) with verified baseline
Sprint 1: Fixed 8 P0 blockers - CFDI entities (schema cfdi→fiscal), auth base DDL,
  billing duplication (→operations), 5 project entities, PaymentInvoiceAllocation,
  core.companies DDL, recreate-database.sh array
Sprint 2: 4 new auth entities, session/role/permission DDL reconciliation,
  CFDI PAC+StampQueue, partner address+contact alignment
Sprint 3: CFDI service+controller+routes, mobile service+controller+routes,
  inventory extended DDL (7 tables)
Sprint 4: timestamp→timestamptz (40 files), field divergences, token/roles/permissions
  service alignment with new DDL-aligned entities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-05 21:51:55 -06:00
parent 651450225e
commit 6a12ff0844
97 changed files with 5852 additions and 509 deletions

View File

@ -29,8 +29,10 @@ import invoicesRoutes from './modules/invoices/invoices.routes.js';
import productsRoutes from './modules/products/products.routes.js'; import productsRoutes from './modules/products/products.routes.js';
import warehousesRoutes from './modules/warehouses/warehouses.routes.js'; import warehousesRoutes from './modules/warehouses/warehouses.routes.js';
import fiscalRoutes from './modules/fiscal/fiscal.routes.js'; import fiscalRoutes from './modules/fiscal/fiscal.routes.js';
import cfdiRoutes from './modules/cfdi/cfdi.routes.js';
import auditRoutes from './modules/audit/audit.routes.js'; import auditRoutes from './modules/audit/audit.routes.js';
import featureFlagsRoutes from './modules/feature-flags/feature-flags.routes.js'; import featureFlagsRoutes from './modules/feature-flags/feature-flags.routes.js';
import mobileRoutes from './modules/mobile/mobile.routes.js';
import { featureFlagsMiddleware } from './modules/feature-flags/middleware/feature-flags.middleware.js'; import { featureFlagsMiddleware } from './modules/feature-flags/middleware/feature-flags.middleware.js';
const app: Application = express(); const app: Application = express();
@ -86,8 +88,10 @@ app.use(`${apiPrefix}/invoices`, invoicesRoutes);
app.use(`${apiPrefix}/products`, productsRoutes); app.use(`${apiPrefix}/products`, productsRoutes);
app.use(`${apiPrefix}/warehouses`, warehousesRoutes); app.use(`${apiPrefix}/warehouses`, warehousesRoutes);
app.use(`${apiPrefix}/fiscal`, fiscalRoutes); app.use(`${apiPrefix}/fiscal`, fiscalRoutes);
app.use(`${apiPrefix}/cfdi`, cfdiRoutes);
app.use(`${apiPrefix}/audit`, auditRoutes); app.use(`${apiPrefix}/audit`, auditRoutes);
app.use(`${apiPrefix}/feature-flags`, featureFlagsRoutes); app.use(`${apiPrefix}/feature-flags`, featureFlagsRoutes);
app.use(`${apiPrefix}/mobile`, mobileRoutes);
// Global helper middlewares (after auth logic if any global auth existed, // Global helper middlewares (after auth logic if any global auth existed,
// but here routes handle auth, so we apply it to be available in all controllers) // but here routes handle auth, so we apply it to be available in all controllers)

View File

@ -23,6 +23,10 @@ import {
OAuthProvider, OAuthProvider,
OAuthUserLink, OAuthUserLink,
OAuthState, OAuthState,
MfaDevice,
MfaBackupCode,
LoginAttempt,
TokenBlacklist,
} from '../modules/auth/entities/index.js'; } from '../modules/auth/entities/index.js';
// Import Core Module Entities // Import Core Module Entities
@ -86,6 +90,15 @@ import {
WithholdingType, WithholdingType,
} from '../modules/fiscal/entities/index.js'; } from '../modules/fiscal/entities/index.js';
// Import CFDI Entities
import { CfdiInvoice } from '../modules/cfdi/entities/cfdi-invoice.entity.js';
import { CfdiCertificate } from '../modules/cfdi/entities/cfdi-certificate.entity.js';
import { CfdiCancellation } from '../modules/cfdi/entities/cfdi-cancellation.entity.js';
import { CfdiLog } from '../modules/cfdi/entities/cfdi-log.entity.js';
import { CfdiPaymentComplement } from '../modules/cfdi/entities/cfdi-payment-complement.entity.js';
import { CfdiPacConfiguration } from '../modules/cfdi/entities/cfdi-pac-configuration.entity.js';
import { CfdiStampQueue } from '../modules/cfdi/entities/cfdi-stamp-queue.entity.js';
// Import Settings Entities // Import Settings Entities
import { import {
SystemSetting, SystemSetting,
@ -130,6 +143,10 @@ export const AppDataSource = new DataSource({
OAuthProvider, OAuthProvider,
OAuthUserLink, OAuthUserLink,
OAuthState, OAuthState,
MfaDevice,
MfaBackupCode,
LoginAttempt,
TokenBlacklist,
// Core Module Entities // Core Module Entities
Partner, Partner,
Currency, Currency,
@ -179,6 +196,14 @@ export const AppDataSource = new DataSource({
PaymentMethod, PaymentMethod,
PaymentType, PaymentType,
WithholdingType, WithholdingType,
// CFDI Entities
CfdiInvoice,
CfdiCertificate,
CfdiCancellation,
CfdiLog,
CfdiPaymentComplement,
CfdiPacConfiguration,
CfdiStampQueue,
// Settings Entities // Settings Entities
SystemSetting, SystemSetting,
PlanSetting, PlanSetting,

View File

@ -69,7 +69,7 @@ export class Company {
users: User[]; users: User[];
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -77,7 +77,7 @@ export class Company {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;
@ -85,7 +85,7 @@ export class Company {
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null; deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) @Column({ type: 'uuid', nullable: true, name: 'deleted_by' })

View File

@ -51,7 +51,7 @@ export class Device {
@Column({ type: 'timestamptz', nullable: true, name: 'last_active_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'last_active_at' })
lastActiveAt: Date; lastActiveAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) @ManyToOne(() => Tenant, { onDelete: 'CASCADE' })

View File

@ -69,19 +69,19 @@ export class Group {
deletedByUser: User | null; deletedByUser: User | null;
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null; createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null; updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null; deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) @Column({ type: 'uuid', nullable: true, name: 'deleted_by' })

View File

@ -20,6 +20,12 @@ export { ProfileModule } from './profile-module.entity.js';
export { UserProfileAssignment } from './user-profile-assignment.entity.js'; export { UserProfileAssignment } from './user-profile-assignment.entity.js';
export { Device } from './device.entity.js'; export { Device } from './device.entity.js';
// Auth-extended entities (06-auth-extended.sql)
export { MfaDevice, MfaDeviceType } from './mfa-device.entity.js';
export { MfaBackupCode } from './mfa-backup-code.entity.js';
export { LoginAttempt } from './login-attempt.entity.js';
export { TokenBlacklist, TokenType } from './token-blacklist.entity.js';
// NOTE: The following entities are also available in their specific modules: // NOTE: The following entities are also available in their specific modules:
// - UserProfile, ProfileTool, ProfileModule, UserProfileAssignment, Person -> profiles/entities/ // - UserProfile, ProfileTool, ProfileModule, UserProfileAssignment, Person -> profiles/entities/
// - Device, BiometricCredential, DeviceSession, DeviceActivityLog -> biometrics/entities/ // - Device, BiometricCredential, DeviceSession, DeviceActivityLog -> biometrics/entities/

View File

@ -0,0 +1,68 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
import { Tenant } from './tenant.entity.js';
@Entity({ schema: 'auth', name: 'login_attempts' })
@Index('idx_login_attempts_email', ['email', 'createdAt'])
@Index('idx_login_attempts_ip', ['ipAddress', 'createdAt'])
@Index('idx_login_attempts_cleanup', ['createdAt'])
export class LoginAttempt {
@PrimaryGeneratedColumn('uuid')
id: string;
// Identification
@Column({ type: 'varchar', length: 255, nullable: true })
email: string | null;
@Column({ type: 'inet', nullable: false, name: 'ip_address' })
ipAddress: string;
@Column({ type: 'text', nullable: true, name: 'user_agent' })
userAgent: string | null;
// Result
@Column({ type: 'boolean', nullable: false })
success: boolean;
@Column({
type: 'varchar',
length: 100,
nullable: true,
name: 'failure_reason',
})
failureReason: string | null;
// MFA
@Column({ type: 'boolean', default: false, name: 'mfa_required' })
mfaRequired: boolean;
@Column({ type: 'boolean', nullable: true, name: 'mfa_passed' })
mfaPassed: boolean | null;
// Metadata
@Column({ type: 'uuid', nullable: true, name: 'tenant_id' })
tenantId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'user_id' })
userId: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@ManyToOne(() => Tenant, { nullable: true })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'user_id' })
user: User | null;
}

View File

@ -0,0 +1,43 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
@Entity({ schema: 'auth', name: 'mfa_backup_codes' })
@Index('idx_mfa_backup_codes_user', ['userId'])
@Index('idx_mfa_backup_codes_unused', ['userId', 'usedAt'])
export class MfaBackupCode {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
// Code (hashed)
@Column({ type: 'varchar', length: 255, nullable: false, name: 'code_hash' })
codeHash: string;
// Status
@Column({ type: 'timestamptz', nullable: true, name: 'used_at' })
usedAt: Date | null;
// Timestamps
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'timestamptz', nullable: true, name: 'expires_at' })
expiresAt: Date | null;
// Relations
@ManyToOne(() => User, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@ -0,0 +1,75 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
export enum MfaDeviceType {
TOTP = 'totp',
SMS = 'sms',
EMAIL = 'email',
HARDWARE_KEY = 'hardware_key',
}
@Entity({ schema: 'auth', name: 'mfa_devices' })
@Index('idx_mfa_devices_user', ['userId'])
@Index('idx_mfa_devices_primary', ['userId', 'isPrimary'])
export class MfaDevice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
// Device info
@Column({ type: 'varchar', length: 50, nullable: false, name: 'device_type' })
deviceType: string;
@Column({
type: 'varchar',
length: 255,
nullable: true,
name: 'device_name',
})
deviceName: string | null;
// TOTP specific
@Column({ type: 'text', nullable: true, name: 'secret_encrypted' })
secretEncrypted: string | null;
// Status
@Column({ type: 'boolean', default: false, name: 'is_primary' })
isPrimary: boolean;
@Column({ type: 'boolean', default: false, name: 'is_verified' })
isVerified: boolean;
@Column({ type: 'timestamptz', nullable: true, name: 'verified_at' })
verifiedAt: Date | null;
// Usage tracking
@Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' })
lastUsedAt: Date | null;
@Column({ type: 'integer', default: 0, name: 'use_count' })
useCount: number;
// Timestamps
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'timestamptz', nullable: true, name: 'disabled_at' })
disabledAt: Date | null;
// Relations
@ManyToOne(() => User, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@ -2,190 +2,88 @@ import {
Entity, Entity,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Column, Column,
CreateDateColumn,
UpdateDateColumn,
Index, Index,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
Unique,
} from 'typeorm'; } from 'typeorm';
import { Tenant } from './tenant.entity.js';
import { User } from './user.entity.js'; import { User } from './user.entity.js';
import { Role } from './role.entity.js'; import { Tenant } from './tenant.entity.js';
@Entity({ schema: 'auth', name: 'oauth_providers' }) @Entity({ schema: 'auth', name: 'oauth_providers' })
@Index('idx_oauth_providers_enabled', ['isEnabled']) @Unique(['provider', 'providerUserId'])
@Index('idx_oauth_providers_tenant', ['tenantId']) @Index('idx_oauth_providers_user', ['userId'])
@Index('idx_oauth_providers_code', ['code']) @Index('idx_oauth_providers_provider', ['provider', 'providerUserId'])
@Index('idx_oauth_providers_email', ['providerEmail'])
export class OAuthProvider { export class OAuthProvider {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ type: 'uuid', nullable: true, name: 'tenant_id' }) @Column({ type: 'uuid', nullable: false, name: 'user_id' })
tenantId: string | null; userId: string;
@Column({ type: 'varchar', length: 50, nullable: false, unique: true }) @Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
code: string; tenantId: string;
@Column({ type: 'varchar', length: 100, nullable: false }) // Provider info
name: string; @Column({ type: 'varchar', length: 50, nullable: false })
provider: string;
// Configuración OAuth2
@Column({ type: 'varchar', length: 255, nullable: false, name: 'client_id' })
clientId: string;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'client_secret' })
clientSecret: string | null;
// Endpoints OAuth2
@Column({
type: 'varchar',
length: 500,
nullable: false,
name: 'authorization_endpoint',
})
authorizationEndpoint: string;
@Column({ @Column({
type: 'varchar', type: 'varchar',
length: 500, length: 255,
nullable: false, nullable: false,
name: 'token_endpoint', name: 'provider_user_id',
}) })
tokenEndpoint: string; providerUserId: string;
@Column({ @Column({
type: 'varchar', type: 'varchar',
length: 500, length: 255,
nullable: false,
name: 'userinfo_endpoint',
})
userinfoEndpoint: string;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'jwks_uri' })
jwksUri: string | null;
// Scopes y parámetros
@Column({
type: 'varchar',
length: 500,
default: 'openid profile email',
nullable: false,
})
scope: string;
@Column({
type: 'varchar',
length: 50,
default: 'code',
nullable: false,
name: 'response_type',
})
responseType: string;
// PKCE Configuration
@Column({
type: 'boolean',
default: true,
nullable: false,
name: 'pkce_enabled',
})
pkceEnabled: boolean;
@Column({
type: 'varchar',
length: 10,
default: 'S256',
nullable: true, nullable: true,
name: 'code_challenge_method', name: 'provider_email',
}) })
codeChallengeMethod: string | null; providerEmail: string | null;
// Mapeo de claims // Tokens (encrypted)
@Column({ type: 'text', nullable: true, name: 'access_token_encrypted' })
accessTokenEncrypted: string | null;
@Column({ type: 'text', nullable: true, name: 'refresh_token_encrypted' })
refreshTokenEncrypted: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'token_expires_at' })
tokenExpiresAt: Date | null;
// Profile data from provider
@Column({ type: 'jsonb', default: '{}', name: 'profile_data' })
profileData: Record<string, any>;
// Timestamps
@Column({ @Column({
type: 'jsonb', type: 'timestamptz',
nullable: false,
name: 'claim_mapping',
default: {
sub: 'oauth_uid',
email: 'email',
name: 'name',
picture: 'avatar_url',
},
})
claimMapping: Record<string, any>;
// UI
@Column({ type: 'varchar', length: 100, nullable: true, name: 'icon_class' })
iconClass: string | null;
@Column({ type: 'varchar', length: 100, nullable: true, name: 'button_text' })
buttonText: string | null;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'button_color' })
buttonColor: string | null;
@Column({
type: 'integer',
default: 10,
nullable: false,
name: 'display_order',
})
displayOrder: number;
// Estado
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_enabled' })
isEnabled: boolean;
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_visible' })
isVisible: boolean;
// Restricciones
@Column({
type: 'text',
array: true,
nullable: true, nullable: true,
name: 'allowed_domains', name: 'linked_at',
default: () => 'CURRENT_TIMESTAMP',
}) })
allowedDomains: string[] | null; linkedAt: Date | null;
@Column({ @Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' })
type: 'boolean', lastUsedAt: Date | null;
default: false,
nullable: false, @Column({ type: 'timestamptz', nullable: true, name: 'unlinked_at' })
name: 'auto_create_users', unlinkedAt: Date | null;
// Relations
@ManyToOne(() => User, {
onDelete: 'CASCADE',
}) })
autoCreateUsers: boolean; @JoinColumn({ name: 'user_id' })
user: User;
@Column({ type: 'uuid', nullable: true, name: 'default_role_id' }) @ManyToOne(() => Tenant, {
defaultRoleId: string | null; onDelete: 'CASCADE',
})
// Relaciones
@ManyToOne(() => Tenant, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'tenant_id' }) @JoinColumn({ name: 'tenant_id' })
tenant: Tenant | null; tenant: Tenant;
@ManyToOne(() => Role, { nullable: true })
@JoinColumn({ name: 'default_role_id' })
defaultRole: Role | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'created_by' })
createdByUser: User | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'updated_by' })
updatedByUser: User | null;
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
} }

View File

@ -23,10 +23,10 @@ export class PasswordReset {
@Column({ type: 'varchar', length: 500, unique: true, nullable: false }) @Column({ type: 'varchar', length: 500, unique: true, nullable: false })
token: string; token: string;
@Column({ type: 'timestamp', nullable: false, name: 'expires_at' }) @Column({ type: 'timestamptz', nullable: false, name: 'expires_at' })
expiresAt: Date; expiresAt: Date;
@Column({ type: 'timestamp', nullable: true, name: 'used_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'used_at' })
usedAt: Date | null; usedAt: Date | null;
@Column({ type: 'inet', nullable: true, name: 'ip_address' }) @Column({ type: 'inet', nullable: true, name: 'ip_address' })
@ -40,6 +40,6 @@ export class PasswordReset {
user: User; user: User;
// Timestamps // Timestamps
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
} }

View File

@ -5,48 +5,67 @@ import {
CreateDateColumn, CreateDateColumn,
Index, Index,
ManyToMany, ManyToMany,
Unique,
} from 'typeorm'; } from 'typeorm';
import { Role } from './role.entity.js'; import { Role } from './role.entity.js';
/**
* Enum for permission actions.
* DDL uses VARCHAR(50) -- not a PostgreSQL enum -- but we keep this TS enum
* for type safety. Values must match the seed data in 07-users-rbac.sql.
*/
export enum PermissionAction { export enum PermissionAction {
CREATE = 'create', CREATE = 'create',
READ = 'read', READ = 'read',
UPDATE = 'update', UPDATE = 'update',
DELETE = 'delete', DELETE = 'delete',
APPROVE = 'approve',
CANCEL = 'cancel',
EXPORT = 'export', EXPORT = 'export',
} }
@Entity({ schema: 'auth', name: 'permissions' }) /**
* Entity: users.permissions
* DDL source: database/ddl/07-users-rbac.sql
*
* Permisos granulares del sistema (resource.action.scope).
* Global -- no tenant_id column.
*/
@Entity({ schema: 'users', name: 'permissions' })
@Unique(['resource', 'action', 'scope'])
@Index('idx_permissions_resource', ['resource']) @Index('idx_permissions_resource', ['resource'])
@Index('idx_permissions_action', ['action']) @Index('idx_permissions_category', ['category'])
@Index('idx_permissions_module', ['module'])
export class Permission { export class Permission {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
// Identificacion
@Column({ type: 'varchar', length: 100, nullable: false }) @Column({ type: 'varchar', length: 100, nullable: false })
resource: string; resource: string;
@Column({ @Column({ type: 'varchar', length: 50, nullable: false })
type: 'enum', action: string;
enum: PermissionAction,
nullable: false, @Column({ type: 'varchar', length: 50, default: "'own'", nullable: true })
}) scope: string;
action: PermissionAction;
// Info
@Column({ type: 'varchar', length: 255, nullable: true, name: 'display_name' })
displayName: string | null;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
description: string | null; description: string | null;
@Column({ type: 'varchar', length: 50, nullable: true }) @Column({ type: 'varchar', length: 100, nullable: true })
module: string | null; category: string | null;
// Relaciones // Flags
@Column({ type: 'boolean', default: false, name: 'is_dangerous' })
isDangerous: boolean;
// Timestamps
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@ManyToMany(() => Role, (role) => role.permissions) @ManyToMany(() => Role, (role) => role.permissions)
roles: Role[]; roles: Role[];
// Sin tenant_id: permisos son globales
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
} }

View File

@ -9,48 +9,111 @@ import {
ManyToMany, ManyToMany,
JoinColumn, JoinColumn,
JoinTable, JoinTable,
OneToMany,
} from 'typeorm'; } from 'typeorm';
import { Tenant } from './tenant.entity.js'; import { Tenant } from './tenant.entity.js';
import { User } from './user.entity.js'; import { User } from './user.entity.js';
import { Permission } from './permission.entity.js'; import { Permission } from './permission.entity.js';
@Entity({ schema: 'auth', name: 'roles' }) /**
@Index('idx_roles_tenant_id', ['tenantId']) * Entity: users.roles
@Index('idx_roles_code', ['code']) * DDL source: database/ddl/07-users-rbac.sql
@Index('idx_roles_is_system', ['isSystem']) *
* Roles del sistema con herencia. Supports hierarchy via parent_role_id,
* system flags, and soft-delete via deleted_at.
*/
@Entity({ schema: 'users', name: 'roles' })
@Index('idx_roles_tenant', ['tenantId'])
@Index('idx_roles_parent', ['parentRoleId'])
@Index('idx_roles_system', ['isSystem'])
@Index('idx_roles_default', ['tenantId', 'isDefault'])
export class Role { export class Role {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) @Column({ type: 'uuid', nullable: true, name: 'tenant_id' })
tenantId: string; tenantId: string | null;
// Info basica
@Column({ type: 'varchar', length: 100, nullable: false }) @Column({ type: 'varchar', length: 100, nullable: false })
name: string; name: string;
@Column({ type: 'varchar', length: 50, nullable: false }) @Column({ type: 'varchar', length: 255, nullable: true, name: 'display_name' })
code: string; displayName: string | null;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
description: string | null; description: string | null;
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' })
isSystem: boolean;
@Column({ type: 'varchar', length: 20, nullable: true }) @Column({ type: 'varchar', length: 20, nullable: true })
color: string | null; color: string | null;
// Relaciones @Column({ type: 'varchar', length: 50, nullable: true })
icon: string | null;
// Jerarquia
@Column({ type: 'uuid', nullable: true, name: 'parent_role_id' })
parentRoleId: string | null;
@Column({ type: 'integer', default: 0, name: 'hierarchy_level' })
hierarchyLevel: number;
// Flags
@Column({ type: 'boolean', default: false, name: 'is_system' })
isSystem: boolean;
@Column({ type: 'boolean', default: false, name: 'is_default' })
isDefault: boolean;
@Column({ type: 'boolean', default: false, name: 'is_superadmin' })
isSuperadmin: boolean;
// Metadata
@Column({ type: 'jsonb', default: '{}' })
metadata: Record<string, any>;
// Timestamps
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
// Relations
@ManyToOne(() => Tenant, (tenant) => tenant.roles, { @ManyToOne(() => Tenant, (tenant) => tenant.roles, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
nullable: true,
}) })
@JoinColumn({ name: 'tenant_id' }) @JoinColumn({ name: 'tenant_id' })
tenant: Tenant; tenant: Tenant | null;
@ManyToOne(() => Role, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'parent_role_id' })
parentRole: Role | null;
@OneToMany(() => Role, (role) => role.parentRole)
childRoles: Role[];
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'created_by' })
creator: User | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'updated_by' })
updater: User | null;
@ManyToMany(() => Permission, (permission) => permission.roles) @ManyToMany(() => Permission, (permission) => permission.roles)
@JoinTable({ @JoinTable({
name: 'role_permissions', name: 'role_permissions',
schema: 'auth', schema: 'users',
joinColumn: { name: 'role_id', referencedColumnName: 'id' }, joinColumn: { name: 'role_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' }, inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
}) })
@ -58,27 +121,4 @@ export class Role {
@ManyToMany(() => User, (user) => user.roles) @ManyToMany(() => User, (user) => user.roles)
users: User[]; users: User[];
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamp',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
} }

View File

@ -8,18 +8,25 @@ import {
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { User } from './user.entity.js'; import { User } from './user.entity.js';
import { Tenant } from './tenant.entity.js';
/**
* Logical session status derived from DDL fields (revoked_at, expires_at).
* DDL does not have a 'status' column -- this enum is used at the application
* layer by token.service.ts for convenience. It is NOT persisted directly.
*/
export enum SessionStatus { export enum SessionStatus {
ACTIVE = 'active', ACTIVE = 'active',
EXPIRED = 'expired',
REVOKED = 'revoked', REVOKED = 'revoked',
EXPIRED = 'expired',
} }
@Entity({ schema: 'auth', name: 'sessions' }) @Entity({ schema: 'auth', name: 'sessions' })
@Index('idx_sessions_user_id', ['userId']) @Index('idx_sessions_user', ['userId'])
@Index('idx_sessions_token', ['token']) @Index('idx_sessions_tenant', ['tenantId'])
@Index('idx_sessions_status', ['status']) @Index('idx_sessions_jti', ['jti'])
@Index('idx_sessions_expires_at', ['expiresAt']) @Index('idx_sessions_expires', ['expiresAt'])
@Index('idx_sessions_active', ['userId', 'revokedAt'])
export class Session { export class Session {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@ -27,57 +34,64 @@ export class Session {
@Column({ type: 'uuid', nullable: false, name: 'user_id' }) @Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string; userId: string;
@Column({ type: 'varchar', length: 500, unique: true, nullable: false }) @Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
token: string; tenantId: string;
// Token info
@Column({
type: 'varchar',
length: 255,
nullable: false,
name: 'refresh_token_hash',
})
refreshTokenHash: string;
@Column({ @Column({
type: 'varchar', type: 'varchar',
length: 500, length: 255,
unique: true, unique: true,
nullable: true,
name: 'refresh_token',
})
refreshToken: string | null;
@Column({
type: 'enum',
enum: SessionStatus,
default: SessionStatus.ACTIVE,
nullable: false, nullable: false,
}) })
status: SessionStatus; jti: string;
@Column({ type: 'timestamp', nullable: false, name: 'expires_at' }) // Device info
expiresAt: Date; @Column({ type: 'jsonb', default: '{}', name: 'device_info' })
deviceInfo: Record<string, any>;
@Column({ @Column({
type: 'timestamp', type: 'varchar',
length: 255,
nullable: true, nullable: true,
name: 'refresh_expires_at', name: 'device_fingerprint',
}) })
refreshExpiresAt: Date | null; deviceFingerprint: string | null;
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
ipAddress: string | null;
@Column({ type: 'text', nullable: true, name: 'user_agent' }) @Column({ type: 'text', nullable: true, name: 'user_agent' })
userAgent: string | null; userAgent: string | null;
@Column({ type: 'jsonb', nullable: true, name: 'device_info' }) @Column({ type: 'inet', nullable: true, name: 'ip_address' })
deviceInfo: Record<string, any> | null; ipAddress: string | null;
// Relaciones // Geo info
@ManyToOne(() => User, (user) => user.sessions, { @Column({ type: 'varchar', length: 2, nullable: true, name: 'country_code' })
onDelete: 'CASCADE', countryCode: string | null;
})
@JoinColumn({ name: 'user_id' }) @Column({ type: 'varchar', length: 100, nullable: true })
user: User; city: string | null;
// Timestamps // Timestamps
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @Column({
createdAt: Date; type: 'timestamptz',
nullable: true,
name: 'last_activity_at',
default: () => 'CURRENT_TIMESTAMP',
})
lastActivityAt: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'revoked_at' }) @Column({ type: 'timestamptz', nullable: false, name: 'expires_at' })
expiresAt: Date;
@Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' })
revokedAt: Date | null; revokedAt: Date | null;
@Column({ @Column({
@ -87,4 +101,20 @@ export class Session {
name: 'revoked_reason', name: 'revoked_reason',
}) })
revokedReason: string | null; revokedReason: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@ManyToOne(() => User, (user) => user.sessions, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => Tenant, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
} }

View File

@ -69,7 +69,7 @@ export class Tenant {
roles: Role[]; roles: Role[];
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -77,7 +77,7 @@ export class Tenant {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;
@ -85,7 +85,7 @@ export class Tenant {
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null; deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) @Column({ type: 'uuid', nullable: true, name: 'deleted_by' })

View File

@ -0,0 +1,66 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
export enum TokenType {
ACCESS = 'access',
REFRESH = 'refresh',
}
@Entity({ schema: 'auth', name: 'token_blacklist' })
@Index('idx_token_blacklist_expires', ['expiresAt'])
@Index('idx_token_blacklist_jti', ['jti'])
export class TokenBlacklist {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, unique: true, nullable: false })
jti: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({
type: 'varchar',
length: 20,
nullable: false,
name: 'token_type',
})
tokenType: string;
// Metadata
@Column({ type: 'varchar', length: 100, nullable: true })
reason: string | null;
@Column({ type: 'uuid', nullable: true, name: 'revoked_by' })
revokedBy: string | null;
// Timestamps
@Column({ type: 'timestamptz', nullable: false, name: 'expires_at' })
expiresAt: Date;
@Column({
type: 'timestamptz',
nullable: true,
name: 'revoked_at',
default: () => 'CURRENT_TIMESTAMP',
})
revokedAt: Date | null;
// Relations
@ManyToOne(() => User, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'revoked_by' })
revokedByUser: User | null;
}

View File

@ -23,7 +23,7 @@ export class UserProfileAssignment {
@Column({ name: 'is_default', default: false }) @Column({ name: 'is_default', default: false })
isDefault: boolean; isDefault: boolean;
@CreateDateColumn({ name: 'assigned_at', type: 'timestamp' }) @CreateDateColumn({ name: 'assigned_at', type: 'timestamptz' })
assignedAt: Date; assignedAt: Date;
@ManyToOne(() => User, { onDelete: 'CASCADE' }) @ManyToOne(() => User, { onDelete: 'CASCADE' })

View File

@ -34,10 +34,10 @@ export class UserProfile {
@Column({ name: 'is_active', default: true }) @Column({ name: 'is_active', default: true })
isActive: boolean; isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date; updatedAt: Date;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) @ManyToOne(() => Tenant, { onDelete: 'CASCADE' })

View File

@ -79,13 +79,13 @@ export class User {
oauthProviderId: string; oauthProviderId: string;
@Column({ @Column({
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
name: 'email_verified_at', name: 'email_verified_at',
}) })
emailVerifiedAt: Date | null; emailVerifiedAt: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'last_login_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'last_login_at' })
lastLoginAt: Date | null; lastLoginAt: Date | null;
@Column({ type: 'inet', nullable: true, name: 'last_login_ip' }) @Column({ type: 'inet', nullable: true, name: 'last_login_ip' })
@ -113,7 +113,7 @@ export class User {
@ManyToMany(() => Role, (role) => role.users) @ManyToMany(() => Role, (role) => role.users)
@JoinTable({ @JoinTable({
name: 'user_roles', name: 'user_roles',
schema: 'auth', schema: 'users',
joinColumn: { name: 'user_id', referencedColumnName: 'id' }, joinColumn: { name: 'user_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' }, inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
}) })
@ -135,7 +135,7 @@ export class User {
passwordResets: PasswordReset[]; passwordResets: PasswordReset[];
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -143,7 +143,7 @@ export class User {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;
@ -151,7 +151,7 @@ export class User {
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null; deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) @Column({ type: 'uuid', nullable: true, name: 'deleted_by' })

View File

@ -1,6 +1,6 @@
import jwt, { SignOptions } from 'jsonwebtoken'; import jwt, { SignOptions } from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Repository } from 'typeorm'; import { IsNull, Not, Repository } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm.js'; import { AppDataSource } from '../../../config/typeorm.js';
import { config } from '../../../config/index.js'; import { config } from '../../../config/index.js';
import { User, Session, SessionStatus } from '../entities/index.js'; import { User, Session, SessionStatus } from '../entities/index.js';
@ -71,7 +71,7 @@ class TokenService {
logger.debug('Generating token pair', { userId: user.id, tenantId: user.tenantId }); logger.debug('Generating token pair', { userId: user.id, tenantId: user.tenantId });
// Extract role codes from user roles // Extract role codes from user roles
const roles = user.roles ? user.roles.map(role => role.code) : []; const roles = user.roles ? user.roles.map(role => role.name) : [];
// Calculate expiration dates // Calculate expiration dates
const accessTokenExpiresAt = this.calculateExpiration(this.ACCESS_TOKEN_EXPIRY); const accessTokenExpiresAt = this.calculateExpiration(this.ACCESS_TOKEN_EXPIRY);
@ -102,11 +102,10 @@ class TokenService {
// Create session record in database // Create session record in database
const session = this.sessionRepository.create({ const session = this.sessionRepository.create({
userId: user.id, userId: user.id,
token: accessJti, // Store JTI instead of full token tenantId: user.tenantId,
refreshToken: refreshJti, // Store JTI instead of full token jti: accessJti,
status: SessionStatus.ACTIVE, refreshTokenHash: refreshJti,
expiresAt: accessTokenExpiresAt, expiresAt: refreshTokenExpiresAt,
refreshExpiresAt: refreshTokenExpiresAt,
ipAddress: metadata.ipAddress, ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent, userAgent: metadata.userAgent,
}); });
@ -153,8 +152,8 @@ class TokenService {
// Find active session with this refresh token JTI // Find active session with this refresh token JTI
const session = await this.sessionRepository.findOne({ const session = await this.sessionRepository.findOne({
where: { where: {
refreshToken: payload.jti, refreshTokenHash: payload.jti,
status: SessionStatus.ACTIVE, revokedAt: IsNull(),
}, },
relations: ['user', 'user.roles'], relations: ['user', 'user.roles'],
}); });
@ -189,10 +188,10 @@ class TokenService {
} }
// Verify session hasn't expired // Verify session hasn't expired
if (session.refreshExpiresAt && new Date() > session.refreshExpiresAt) { if (session.expiresAt && new Date() > session.expiresAt) {
logger.warn('Refresh token expired', { logger.warn('Refresh token expired', {
sessionId: session.id, sessionId: session.id,
expiredAt: session.refreshExpiresAt, expiredAt: session.expiresAt,
}); });
await this.revokeSession(session.id, 'Token expired'); await this.revokeSession(session.id, 'Token expired');
@ -200,7 +199,6 @@ class TokenService {
} }
// Mark current session as used (revoke it) // Mark current session as used (revoke it)
session.status = SessionStatus.REVOKED;
session.revokedAt = new Date(); session.revokedAt = new Date();
session.revokedReason = 'Used for refresh'; session.revokedReason = 'Used for refresh';
await this.sessionRepository.save(session); await this.sessionRepository.save(session);
@ -242,7 +240,6 @@ class TokenService {
} }
// Mark session as revoked // Mark session as revoked
session.status = SessionStatus.REVOKED;
session.revokedAt = new Date(); session.revokedAt = new Date();
session.revokedReason = reason; session.revokedReason = reason;
await this.sessionRepository.save(session); await this.sessionRepository.save(session);
@ -250,7 +247,7 @@ class TokenService {
// Blacklist the access token (JTI) in Redis // Blacklist the access token (JTI) in Redis
const remainingTTL = this.calculateRemainingTTL(session.expiresAt); const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
if (remainingTTL > 0) { if (remainingTTL > 0) {
await this.blacklistAccessToken(session.token, remainingTTL); await this.blacklistAccessToken(session.jti, remainingTTL);
} }
logger.info('Session revoked successfully', { sessionId, reason }); logger.info('Session revoked successfully', { sessionId, reason });
@ -277,7 +274,7 @@ class TokenService {
const sessions = await this.sessionRepository.find({ const sessions = await this.sessionRepository.find({
where: { where: {
userId, userId,
status: SessionStatus.ACTIVE, revokedAt: IsNull(),
}, },
}); });
@ -288,14 +285,13 @@ class TokenService {
// Revoke each session // Revoke each session
for (const session of sessions) { for (const session of sessions) {
session.status = SessionStatus.REVOKED;
session.revokedAt = new Date(); session.revokedAt = new Date();
session.revokedReason = reason; session.revokedReason = reason;
// Blacklist access token // Blacklist access token
const remainingTTL = this.calculateRemainingTTL(session.expiresAt); const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
if (remainingTTL > 0) { if (remainingTTL > 0) {
await this.blacklistAccessToken(session.token, remainingTTL); await this.blacklistAccessToken(session.jti, remainingTTL);
} }
} }

View File

@ -0,0 +1,686 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import {
cfdiService,
CreateCfdiInvoiceDto,
UpdateCfdiInvoiceDto,
CfdiFilters,
CertificateFilters,
UploadCertificateDto,
CancellationRequestDto,
} from './services/index.js';
import { CfdiStatus } from './enums/cfdi-status.enum.js';
import { CancellationReason } from './enums/cancellation-reason.enum.js';
import { CfdiVoucherType } from './entities/cfdi-invoice.entity.js';
import { CertificateStatus } from './entities/cfdi-certificate.entity.js';
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
// ===== Zod Validation Schemas =====
const cfdiStatusValues = ['draft', 'pending', 'processing', 'stamped', 'error', 'cancelled', 'cancellation_pending', 'cancellation_rejected'] as const;
const voucherTypeValues = ['I', 'E', 'T', 'N', 'P'] as const;
const cancellationReasonValues = ['01', '02', '03', '04'] as const;
const certificateStatusValues = ['active', 'inactive', 'expired', 'revoked', 'pending'] as const;
const createCfdiSchema = z.object({
// Dual case support (snake_case from frontend, camelCase)
invoice_id: z.string().uuid().optional(),
invoiceId: z.string().uuid().optional(),
certificate_id: z.string().uuid().optional(),
certificateId: z.string().uuid().optional(),
serie: z.string().max(25).optional(),
folio: z.string().max(40).optional(),
cfdi_version: z.string().max(5).optional(),
cfdiVersion: z.string().max(5).optional(),
voucher_type: z.enum(voucherTypeValues).optional(),
voucherType: z.enum(voucherTypeValues).optional(),
// Issuer (Emisor)
issuer_rfc: z.string().min(12).max(13).optional(),
issuerRfc: z.string().min(12).max(13).optional(),
issuer_name: z.string().min(1).max(300).optional(),
issuerName: z.string().min(1).max(300).optional(),
issuer_fiscal_regime: z.string().max(10).optional(),
issuerFiscalRegime: z.string().max(10).optional(),
// Receiver (Receptor)
receiver_rfc: z.string().min(12).max(13).optional(),
receiverRfc: z.string().min(12).max(13).optional(),
receiver_name: z.string().min(1).max(300).optional(),
receiverName: z.string().min(1).max(300).optional(),
receiver_fiscal_regime: z.string().max(10).optional(),
receiverFiscalRegime: z.string().max(10).optional(),
receiver_zip_code: z.string().max(5).optional(),
receiverZipCode: z.string().max(5).optional(),
receiver_tax_residence: z.string().max(3).optional(),
receiverTaxResidence: z.string().max(3).optional(),
receiver_tax_id: z.string().max(40).optional(),
receiverTaxId: z.string().max(40).optional(),
cfdi_use: z.string().max(10).optional(),
cfdiUse: z.string().max(10).optional(),
// Amounts
subtotal: z.coerce.number().min(0),
discount: z.coerce.number().min(0).optional(),
total: z.coerce.number().min(0),
total_transferred_taxes: z.coerce.number().optional(),
totalTransferredTaxes: z.coerce.number().optional(),
total_withheld_taxes: z.coerce.number().optional(),
totalWithheldTaxes: z.coerce.number().optional(),
currency: z.string().max(3).optional(),
exchange_rate: z.coerce.number().optional(),
exchangeRate: z.coerce.number().optional(),
exportation: z.string().max(2).optional(),
// Payment
payment_form: z.string().max(10).optional(),
paymentForm: z.string().max(10).optional(),
payment_method: z.string().max(10).optional(),
paymentMethod: z.string().max(10).optional(),
payment_conditions: z.string().max(1000).optional(),
paymentConditions: z.string().max(1000).optional(),
// Location
expedition_place: z.string().max(5).optional(),
expeditionPlace: z.string().max(5).optional(),
confirmation_code: z.string().max(17).optional(),
confirmationCode: z.string().max(17).optional(),
// Related CFDI
related_cfdi_type: z.string().max(10).optional(),
relatedCfdiType: z.string().max(10).optional(),
// Global info
global_info_periodicity: z.string().max(10).optional(),
globalInfoPeriodicity: z.string().max(10).optional(),
global_info_months: z.string().max(10).optional(),
globalInfoMonths: z.string().max(10).optional(),
global_info_year: z.string().max(4).optional(),
globalInfoYear: z.string().max(4).optional(),
});
const updateCfdiSchema = z.object({
invoice_id: z.string().uuid().optional().nullable(),
invoiceId: z.string().uuid().optional().nullable(),
certificate_id: z.string().uuid().optional().nullable(),
certificateId: z.string().uuid().optional().nullable(),
serie: z.string().max(25).optional().nullable(),
folio: z.string().max(40).optional().nullable(),
voucher_type: z.enum(voucherTypeValues).optional(),
voucherType: z.enum(voucherTypeValues).optional(),
issuer_rfc: z.string().min(12).max(13).optional(),
issuerRfc: z.string().min(12).max(13).optional(),
issuer_name: z.string().min(1).max(300).optional(),
issuerName: z.string().min(1).max(300).optional(),
issuer_fiscal_regime: z.string().max(10).optional(),
issuerFiscalRegime: z.string().max(10).optional(),
receiver_rfc: z.string().min(12).max(13).optional(),
receiverRfc: z.string().min(12).max(13).optional(),
receiver_name: z.string().min(1).max(300).optional(),
receiverName: z.string().min(1).max(300).optional(),
receiver_fiscal_regime: z.string().max(10).optional().nullable(),
receiverFiscalRegime: z.string().max(10).optional().nullable(),
receiver_zip_code: z.string().max(5).optional().nullable(),
receiverZipCode: z.string().max(5).optional().nullable(),
receiver_tax_residence: z.string().max(3).optional().nullable(),
receiverTaxResidence: z.string().max(3).optional().nullable(),
receiver_tax_id: z.string().max(40).optional().nullable(),
receiverTaxId: z.string().max(40).optional().nullable(),
cfdi_use: z.string().max(10).optional(),
cfdiUse: z.string().max(10).optional(),
subtotal: z.coerce.number().min(0).optional(),
discount: z.coerce.number().min(0).optional().nullable(),
total: z.coerce.number().min(0).optional(),
total_transferred_taxes: z.coerce.number().optional().nullable(),
totalTransferredTaxes: z.coerce.number().optional().nullable(),
total_withheld_taxes: z.coerce.number().optional().nullable(),
totalWithheldTaxes: z.coerce.number().optional().nullable(),
currency: z.string().max(3).optional(),
exchange_rate: z.coerce.number().optional().nullable(),
exchangeRate: z.coerce.number().optional().nullable(),
exportation: z.string().max(2).optional().nullable(),
payment_form: z.string().max(10).optional(),
paymentForm: z.string().max(10).optional(),
payment_method: z.string().max(10).optional(),
paymentMethod: z.string().max(10).optional(),
payment_conditions: z.string().max(1000).optional().nullable(),
paymentConditions: z.string().max(1000).optional().nullable(),
expedition_place: z.string().max(5).optional(),
expeditionPlace: z.string().max(5).optional(),
confirmation_code: z.string().max(17).optional().nullable(),
confirmationCode: z.string().max(17).optional().nullable(),
related_cfdi_type: z.string().max(10).optional().nullable(),
relatedCfdiType: z.string().max(10).optional().nullable(),
global_info_periodicity: z.string().max(10).optional().nullable(),
globalInfoPeriodicity: z.string().max(10).optional().nullable(),
global_info_months: z.string().max(10).optional().nullable(),
globalInfoMonths: z.string().max(10).optional().nullable(),
global_info_year: z.string().max(4).optional().nullable(),
globalInfoYear: z.string().max(4).optional().nullable(),
});
const cfdiQuerySchema = z.object({
search: z.string().optional(),
status: z.enum(cfdiStatusValues).optional(),
voucher_type: z.enum(voucherTypeValues).optional(),
voucherType: z.enum(voucherTypeValues).optional(),
issuer_rfc: z.string().optional(),
issuerRfc: z.string().optional(),
receiver_rfc: z.string().optional(),
receiverRfc: z.string().optional(),
serie: z.string().optional(),
folio: z.string().optional(),
date_from: z.string().optional(),
dateFrom: z.string().optional(),
date_to: z.string().optional(),
dateTo: z.string().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
const cancelCfdiSchema = z.object({
cancellation_reason: z.enum(cancellationReasonValues).optional(),
cancellationReason: z.enum(cancellationReasonValues).optional(),
substitute_uuid: z.string().uuid().optional(),
substituteUuid: z.string().uuid().optional(),
substitute_cfdi_id: z.string().uuid().optional(),
substituteCfdiId: z.string().uuid().optional(),
reason_notes: z.string().max(2000).optional(),
reasonNotes: z.string().max(2000).optional(),
internal_notes: z.string().max(2000).optional(),
internalNotes: z.string().max(2000).optional(),
});
const certificateQuerySchema = z.object({
rfc: z.string().optional(),
status: z.enum(certificateStatusValues).optional(),
is_default: z.coerce.boolean().optional(),
isDefault: z.coerce.boolean().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
const uploadCertificateSchema = z.object({
rfc: z.string().min(12).max(13),
certificate_number: z.string().max(20).optional(),
certificateNumber: z.string().max(20).optional(),
certificate_pem: z.string().min(1).optional(),
certificatePem: z.string().min(1).optional(),
private_key_pem_encrypted: z.string().min(1).optional(),
privateKeyPemEncrypted: z.string().min(1).optional(),
serial_number: z.string().max(100).optional(),
serialNumber: z.string().max(100).optional(),
issued_at: z.string().optional(),
issuedAt: z.string().optional(),
expires_at: z.string().optional(),
expiresAt: z.string().optional(),
is_default: z.boolean().optional(),
isDefault: z.boolean().optional(),
issuer_name: z.string().max(500).optional(),
issuerName: z.string().max(500).optional(),
subject_name: z.string().max(500).optional(),
subjectName: z.string().max(500).optional(),
description: z.string().max(255).optional(),
notes: z.string().optional(),
});
// ===== CfdiController Class =====
class CfdiController {
/**
* GET /api/cfdi - List CFDI invoices with filters and pagination
*/
async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const queryResult = cfdiQuerySchema.safeParse(req.query);
if (!queryResult.success) {
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
}
const data = queryResult.data;
const tenantId = req.user!.tenantId;
const filters: CfdiFilters = {
search: data.search,
status: data.status as CfdiStatus | undefined,
voucherType: data.voucherType || data.voucher_type,
issuerRfc: data.issuerRfc || data.issuer_rfc,
receiverRfc: data.receiverRfc || data.receiver_rfc,
serie: data.serie,
folio: data.folio,
dateFrom: data.dateFrom || data.date_from,
dateTo: data.dateTo || data.date_to,
page: data.page,
limit: data.limit,
};
const result = await cfdiService.findAll(tenantId, filters);
const response: ApiResponse = {
success: true,
data: result.data,
meta: {
total: result.total,
page: filters.page || 1,
limit: filters.limit || 20,
totalPages: Math.ceil(result.total / (filters.limit || 20)),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* GET /api/cfdi/:id - Get single CFDI by ID
*/
async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const tenantId = req.user!.tenantId;
const invoice = await cfdiService.findById(tenantId, id);
const response: ApiResponse = {
success: true,
data: invoice,
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* POST /api/cfdi - Create new CFDI invoice in draft
*/
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = createCfdiSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de CFDI invalidos', parseResult.error.errors);
}
const data = parseResult.data;
const tenantId = req.user!.tenantId;
const userId = req.user!.userId;
// Transform to camelCase DTO
const dto: CreateCfdiInvoiceDto = {
issuerRfc: (data.issuerRfc || data.issuer_rfc)!,
issuerName: (data.issuerName || data.issuer_name)!,
issuerFiscalRegime: (data.issuerFiscalRegime || data.issuer_fiscal_regime)!,
receiverRfc: (data.receiverRfc || data.receiver_rfc)!,
receiverName: (data.receiverName || data.receiver_name)!,
cfdiUse: (data.cfdiUse || data.cfdi_use)!,
subtotal: data.subtotal,
total: data.total,
paymentForm: (data.paymentForm || data.payment_form)!,
paymentMethod: (data.paymentMethod || data.payment_method)!,
expeditionPlace: (data.expeditionPlace || data.expedition_place)!,
};
// Optional fields
if (data.invoiceId !== undefined || data.invoice_id !== undefined) {
dto.invoiceId = data.invoiceId || data.invoice_id;
}
if (data.certificateId !== undefined || data.certificate_id !== undefined) {
dto.certificateId = data.certificateId || data.certificate_id;
}
if (data.serie !== undefined) dto.serie = data.serie;
if (data.folio !== undefined) dto.folio = data.folio;
if (data.cfdiVersion !== undefined || data.cfdi_version !== undefined) {
dto.cfdiVersion = data.cfdiVersion || data.cfdi_version;
}
if (data.voucherType !== undefined || data.voucher_type !== undefined) {
dto.voucherType = data.voucherType || data.voucher_type;
}
if (data.receiverFiscalRegime !== undefined || data.receiver_fiscal_regime !== undefined) {
dto.receiverFiscalRegime = data.receiverFiscalRegime || data.receiver_fiscal_regime;
}
if (data.receiverZipCode !== undefined || data.receiver_zip_code !== undefined) {
dto.receiverZipCode = data.receiverZipCode || data.receiver_zip_code;
}
if (data.receiverTaxResidence !== undefined || data.receiver_tax_residence !== undefined) {
dto.receiverTaxResidence = data.receiverTaxResidence || data.receiver_tax_residence;
}
if (data.receiverTaxId !== undefined || data.receiver_tax_id !== undefined) {
dto.receiverTaxId = data.receiverTaxId || data.receiver_tax_id;
}
if (data.discount !== undefined) dto.discount = data.discount;
if (data.totalTransferredTaxes !== undefined || data.total_transferred_taxes !== undefined) {
dto.totalTransferredTaxes = data.totalTransferredTaxes ?? data.total_transferred_taxes;
}
if (data.totalWithheldTaxes !== undefined || data.total_withheld_taxes !== undefined) {
dto.totalWithheldTaxes = data.totalWithheldTaxes ?? data.total_withheld_taxes;
}
if (data.currency !== undefined) dto.currency = data.currency;
if (data.exchangeRate !== undefined || data.exchange_rate !== undefined) {
dto.exchangeRate = data.exchangeRate ?? data.exchange_rate;
}
if (data.exportation !== undefined) dto.exportation = data.exportation;
if (data.paymentConditions !== undefined || data.payment_conditions !== undefined) {
dto.paymentConditions = data.paymentConditions || data.payment_conditions;
}
if (data.confirmationCode !== undefined || data.confirmation_code !== undefined) {
dto.confirmationCode = data.confirmationCode || data.confirmation_code;
}
if (data.relatedCfdiType !== undefined || data.related_cfdi_type !== undefined) {
dto.relatedCfdiType = data.relatedCfdiType || data.related_cfdi_type;
}
if (data.globalInfoPeriodicity !== undefined || data.global_info_periodicity !== undefined) {
dto.globalInfoPeriodicity = data.globalInfoPeriodicity || data.global_info_periodicity;
}
if (data.globalInfoMonths !== undefined || data.global_info_months !== undefined) {
dto.globalInfoMonths = data.globalInfoMonths || data.global_info_months;
}
if (data.globalInfoYear !== undefined || data.global_info_year !== undefined) {
dto.globalInfoYear = data.globalInfoYear || data.global_info_year;
}
const invoice = await cfdiService.create(tenantId, dto, userId);
const response: ApiResponse = {
success: true,
data: invoice,
message: 'CFDI creado exitosamente en estado borrador',
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
/**
* PUT /api/cfdi/:id - Update a draft CFDI
*/
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const parseResult = updateCfdiSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de CFDI invalidos', parseResult.error.errors);
}
const data = parseResult.data;
const tenantId = req.user!.tenantId;
const userId = req.user!.userId;
// Transform to camelCase DTO
const dto: UpdateCfdiInvoiceDto = {};
if (data.invoiceId !== undefined || data.invoice_id !== undefined) {
dto.invoiceId = data.invoiceId ?? data.invoice_id;
}
if (data.certificateId !== undefined || data.certificate_id !== undefined) {
dto.certificateId = data.certificateId ?? data.certificate_id;
}
if (data.serie !== undefined) dto.serie = data.serie;
if (data.folio !== undefined) dto.folio = data.folio;
if (data.voucherType !== undefined || data.voucher_type !== undefined) {
dto.voucherType = data.voucherType ?? data.voucher_type;
}
if (data.issuerRfc !== undefined || data.issuer_rfc !== undefined) {
dto.issuerRfc = data.issuerRfc ?? data.issuer_rfc;
}
if (data.issuerName !== undefined || data.issuer_name !== undefined) {
dto.issuerName = data.issuerName ?? data.issuer_name;
}
if (data.issuerFiscalRegime !== undefined || data.issuer_fiscal_regime !== undefined) {
dto.issuerFiscalRegime = data.issuerFiscalRegime ?? data.issuer_fiscal_regime;
}
if (data.receiverRfc !== undefined || data.receiver_rfc !== undefined) {
dto.receiverRfc = data.receiverRfc ?? data.receiver_rfc;
}
if (data.receiverName !== undefined || data.receiver_name !== undefined) {
dto.receiverName = data.receiverName ?? data.receiver_name;
}
if (data.receiverFiscalRegime !== undefined || data.receiver_fiscal_regime !== undefined) {
dto.receiverFiscalRegime = data.receiverFiscalRegime ?? data.receiver_fiscal_regime;
}
if (data.receiverZipCode !== undefined || data.receiver_zip_code !== undefined) {
dto.receiverZipCode = data.receiverZipCode ?? data.receiver_zip_code;
}
if (data.receiverTaxResidence !== undefined || data.receiver_tax_residence !== undefined) {
dto.receiverTaxResidence = data.receiverTaxResidence ?? data.receiver_tax_residence;
}
if (data.receiverTaxId !== undefined || data.receiver_tax_id !== undefined) {
dto.receiverTaxId = data.receiverTaxId ?? data.receiver_tax_id;
}
if (data.cfdiUse !== undefined || data.cfdi_use !== undefined) {
dto.cfdiUse = data.cfdiUse ?? data.cfdi_use;
}
if (data.subtotal !== undefined) dto.subtotal = data.subtotal;
if (data.discount !== undefined) dto.discount = data.discount;
if (data.total !== undefined) dto.total = data.total;
if (data.totalTransferredTaxes !== undefined || data.total_transferred_taxes !== undefined) {
dto.totalTransferredTaxes = data.totalTransferredTaxes ?? data.total_transferred_taxes;
}
if (data.totalWithheldTaxes !== undefined || data.total_withheld_taxes !== undefined) {
dto.totalWithheldTaxes = data.totalWithheldTaxes ?? data.total_withheld_taxes;
}
if (data.currency !== undefined) dto.currency = data.currency;
if (data.exchangeRate !== undefined || data.exchange_rate !== undefined) {
dto.exchangeRate = data.exchangeRate ?? data.exchange_rate;
}
if (data.exportation !== undefined) dto.exportation = data.exportation;
if (data.paymentForm !== undefined || data.payment_form !== undefined) {
dto.paymentForm = data.paymentForm ?? data.payment_form;
}
if (data.paymentMethod !== undefined || data.payment_method !== undefined) {
dto.paymentMethod = data.paymentMethod ?? data.payment_method;
}
if (data.paymentConditions !== undefined || data.payment_conditions !== undefined) {
dto.paymentConditions = data.paymentConditions ?? data.payment_conditions;
}
if (data.expeditionPlace !== undefined || data.expedition_place !== undefined) {
dto.expeditionPlace = data.expeditionPlace ?? data.expedition_place;
}
if (data.confirmationCode !== undefined || data.confirmation_code !== undefined) {
dto.confirmationCode = data.confirmationCode ?? data.confirmation_code;
}
if (data.relatedCfdiType !== undefined || data.related_cfdi_type !== undefined) {
dto.relatedCfdiType = data.relatedCfdiType ?? data.related_cfdi_type;
}
if (data.globalInfoPeriodicity !== undefined || data.global_info_periodicity !== undefined) {
dto.globalInfoPeriodicity = data.globalInfoPeriodicity ?? data.global_info_periodicity;
}
if (data.globalInfoMonths !== undefined || data.global_info_months !== undefined) {
dto.globalInfoMonths = data.globalInfoMonths ?? data.global_info_months;
}
if (data.globalInfoYear !== undefined || data.global_info_year !== undefined) {
dto.globalInfoYear = data.globalInfoYear ?? data.global_info_year;
}
const invoice = await cfdiService.update(tenantId, id, dto, userId);
const response: ApiResponse = {
success: true,
data: invoice,
message: 'CFDI actualizado exitosamente',
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* POST /api/cfdi/:id/stamp - Request stamping for a CFDI
*/
async requestStamp(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const tenantId = req.user!.tenantId;
const userId = req.user!.userId;
const invoice = await cfdiService.requestStamp(tenantId, id, userId);
const response: ApiResponse = {
success: true,
data: invoice,
message: 'Solicitud de timbrado registrada. El CFDI sera procesado en la cola de timbrado.',
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* POST /api/cfdi/:id/cancel - Request cancellation for a CFDI
*/
async requestCancellation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const parseResult = cancelCfdiSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de cancelacion invalidos', parseResult.error.errors);
}
const data = parseResult.data;
const tenantId = req.user!.tenantId;
const userId = req.user!.userId;
const reasonValue = data.cancellationReason || data.cancellation_reason;
if (!reasonValue) {
throw new ValidationError('El motivo de cancelacion es requerido (cancellationReason).');
}
const dto: CancellationRequestDto = {
cancellationReason: reasonValue as CancellationReason,
substituteUuid: data.substituteUuid || data.substitute_uuid,
substituteCfdiId: data.substituteCfdiId || data.substitute_cfdi_id,
reasonNotes: data.reasonNotes || data.reason_notes,
internalNotes: data.internalNotes || data.internal_notes,
};
const cancellation = await cfdiService.requestCancellation(tenantId, id, dto, userId);
const response: ApiResponse = {
success: true,
data: cancellation,
message: 'Solicitud de cancelacion registrada exitosamente.',
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* GET /api/cfdi/:id/status - Get CFDI status including SAT validation
*/
async getStatus(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const tenantId = req.user!.tenantId;
const status = await cfdiService.getStatus(tenantId, id);
const response: ApiResponse = {
success: true,
data: status,
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* GET /api/cfdi/certificates - List certificates
*/
async listCertificates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const queryResult = certificateQuerySchema.safeParse(req.query);
if (!queryResult.success) {
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
}
const data = queryResult.data;
const tenantId = req.user!.tenantId;
const filters: CertificateFilters = {
rfc: data.rfc,
status: data.status as CertificateStatus | undefined,
isDefault: data.isDefault ?? data.is_default,
page: data.page,
limit: data.limit,
};
const result = await cfdiService.listCertificates(tenantId, filters);
const response: ApiResponse = {
success: true,
data: result.data,
meta: {
total: result.total,
page: filters.page || 1,
limit: filters.limit || 20,
totalPages: Math.ceil(result.total / (filters.limit || 20)),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* POST /api/cfdi/certificates - Upload a new certificate
*/
async uploadCertificate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = uploadCertificateSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de certificado invalidos', parseResult.error.errors);
}
const data = parseResult.data;
const tenantId = req.user!.tenantId;
const userId = req.user!.userId;
const certificateNumber = data.certificateNumber || data.certificate_number;
const certificatePem = data.certificatePem || data.certificate_pem;
const privateKeyPemEncrypted = data.privateKeyPemEncrypted || data.private_key_pem_encrypted;
const serialNumber = data.serialNumber || data.serial_number;
const issuedAt = data.issuedAt || data.issued_at;
const expiresAt = data.expiresAt || data.expires_at;
if (!certificateNumber || !certificatePem || !privateKeyPemEncrypted || !serialNumber || !issuedAt || !expiresAt) {
throw new ValidationError(
'Campos requeridos faltantes: certificateNumber, certificatePem, privateKeyPemEncrypted, serialNumber, issuedAt, expiresAt'
);
}
const dto: UploadCertificateDto = {
rfc: data.rfc,
certificateNumber,
certificatePem,
privateKeyPemEncrypted,
serialNumber,
issuedAt,
expiresAt,
isDefault: data.isDefault ?? data.is_default,
issuerName: data.issuerName || data.issuer_name,
subjectName: data.subjectName || data.subject_name,
description: data.description,
notes: data.notes,
};
const certificate = await cfdiService.uploadCertificate(tenantId, dto, userId);
const response: ApiResponse = {
success: true,
data: certificate,
message: 'Certificado registrado exitosamente',
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
}
export const cfdiController = new CfdiController();

View File

@ -0,0 +1,59 @@
/**
* CFDI Module
*
* Módulo para gestión de Comprobantes Fiscales Digitales por Internet (CFDI)
* Incluye timbrado, cancelación y complementos de pago
*/
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { CfdiCertificate } from './entities/cfdi-certificate.entity.js';
import { CfdiInvoice } from './entities/cfdi-invoice.entity.js';
import { CfdiCancellation } from './entities/cfdi-cancellation.entity.js';
import { CfdiLog } from './entities/cfdi-log.entity.js';
import { CfdiPaymentComplement } from './entities/cfdi-payment-complement.entity.js';
import { CfdiPacConfiguration } from './entities/cfdi-pac-configuration.entity.js';
import { CfdiStampQueue } from './entities/cfdi-stamp-queue.entity.js';
import cfdiRoutes from './cfdi.routes.js';
export interface CfdiModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class CfdiModule {
public router: Router;
constructor(options: CfdiModuleOptions) {
const { basePath = '/cfdi' } = options;
this.router = Router();
// Mount CFDI routes under the base path
this.router.use(basePath, cfdiRoutes);
}
/**
* Get all entities for this module (for TypeORM configuration)
*/
static getEntities() {
return [
CfdiCertificate,
CfdiInvoice,
CfdiCancellation,
CfdiLog,
CfdiPaymentComplement,
CfdiPacConfiguration,
CfdiStampQueue,
];
}
}
export default CfdiModule;
// Re-export all module components
export * from './entities/index.js';
export * from './dto/index.js';
export * from './enums/index.js';
export * from './interfaces/index.js';
export * from './services/index.js';

View File

@ -0,0 +1,82 @@
import { Router } from 'express';
import { cfdiController } from './cfdi.controller.js';
import { authenticate } from '../../shared/middleware/auth.middleware.js';
import { requireAccess } from '../../shared/middleware/rbac.middleware.js';
const router = Router();
// All CFDI routes require authentication
router.use(authenticate);
// ============================================================================
// CERTIFICATE ROUTES (must be before /:id routes to avoid conflicts)
// ============================================================================
// List certificates (admin, manager, accountant)
router.get(
'/certificates',
requireAccess({ roles: ['admin', 'manager', 'accountant'], permission: 'cfdi:read' }),
(req, res, next) => cfdiController.listCertificates(req, res, next)
);
// Upload certificate (admin only)
router.post(
'/certificates',
requireAccess({ roles: ['admin'], permission: 'cfdi:manage_certificates' }),
(req, res, next) => cfdiController.uploadCertificate(req, res, next)
);
// ============================================================================
// CFDI INVOICE ROUTES
// ============================================================================
// List CFDIs (admin, manager, accountant)
router.get(
'/',
requireAccess({ roles: ['admin', 'manager', 'accountant'], permission: 'cfdi:read' }),
(req, res, next) => cfdiController.findAll(req, res, next)
);
// Get CFDI by ID (admin, manager, accountant)
router.get(
'/:id',
requireAccess({ roles: ['admin', 'manager', 'accountant'], permission: 'cfdi:read' }),
(req, res, next) => cfdiController.findById(req, res, next)
);
// Get CFDI status (admin, manager, accountant)
router.get(
'/:id/status',
requireAccess({ roles: ['admin', 'manager', 'accountant'], permission: 'cfdi:read' }),
(req, res, next) => cfdiController.getStatus(req, res, next)
);
// Create CFDI (admin, manager, accountant)
router.post(
'/',
requireAccess({ roles: ['admin', 'manager', 'accountant'], permission: 'cfdi:create' }),
(req, res, next) => cfdiController.create(req, res, next)
);
// Update draft CFDI (admin, manager, accountant)
router.put(
'/:id',
requireAccess({ roles: ['admin', 'manager', 'accountant'], permission: 'cfdi:update' }),
(req, res, next) => cfdiController.update(req, res, next)
);
// Request stamping (admin, manager, accountant)
router.post(
'/:id/stamp',
requireAccess({ roles: ['admin', 'manager', 'accountant'], permission: 'cfdi:stamp' }),
(req, res, next) => cfdiController.requestStamp(req, res, next)
);
// Request cancellation (admin, manager)
router.post(
'/:id/cancel',
requireAccess({ roles: ['admin', 'manager'], permission: 'cfdi:cancel' }),
(req, res, next) => cfdiController.requestCancellation(req, res, next)
);
export default router;

View File

@ -0,0 +1,158 @@
import {
IsString,
IsUUID,
IsOptional,
IsEnum,
MaxLength,
MinLength,
} from 'class-validator';
import { CancellationReason } from '../enums/cancellation-reason.enum.js';
/**
* DTO para cancelar un CFDI
*/
export class CancelInvoiceDto {
@IsUUID()
tenantId: string;
@IsUUID()
cfdiInvoiceId: string;
@IsEnum(CancellationReason)
cancellationReason: CancellationReason;
@IsUUID()
@IsOptional()
replacementUuid?: string;
}
/**
* DTO para cancelar por UUID directamente
*/
export class CancelByUuidDto {
@IsUUID()
tenantId: string;
@IsString()
@MinLength(12)
@MaxLength(13)
rfcEmisor: string;
@IsUUID()
uuid: string;
@IsEnum(CancellationReason)
cancellationReason: CancellationReason;
@IsUUID()
@IsOptional()
replacementUuid?: string;
}
/**
* DTO de respuesta de cancelación
*/
export class CancelResponseDto {
success: boolean;
cancellationId: string;
uuid: string;
status: string;
acuseXml?: string;
cancellationDate?: Date;
satStatus?: string;
errorCode?: string;
errorMessage?: string;
}
/**
* DTO para responder a una solicitud de cancelación
* (cuando el receptor debe aceptar/rechazar)
*/
export class CancellationAnswerDto {
@IsUUID()
tenantId: string;
@IsString()
@MinLength(12)
@MaxLength(13)
rfcReceptor: string;
@IsUUID()
uuid: string;
@IsString()
answer: 'accept' | 'reject';
@IsString()
@IsOptional()
@MaxLength(500)
rejectionReason?: string;
}
/**
* DTO para listar cancelaciones pendientes
*/
export class PendingCancellationsFilterDto {
@IsUUID()
tenantId: string;
@IsString()
@MinLength(12)
@MaxLength(13)
rfc: string;
}
/**
* DTO para filtrar CFDIs
*/
export class CfdiFilterDto {
@IsUUID()
@IsOptional()
tenantId?: string;
@IsUUID()
@IsOptional()
companyId?: string;
@IsString()
@IsOptional()
rfcEmisor?: string;
@IsString()
@IsOptional()
rfcReceptor?: string;
@IsString()
@IsOptional()
status?: string;
@IsString()
@IsOptional()
cfdiType?: string;
@IsString()
@IsOptional()
uuid?: string;
@IsString()
@IsOptional()
serie?: string;
@IsString()
@IsOptional()
folio?: string;
@IsString()
@IsOptional()
dateFrom?: string;
@IsString()
@IsOptional()
dateTo?: string;
@IsOptional()
limit?: number;
@IsOptional()
offset?: number;
}

View File

@ -0,0 +1,96 @@
import { IsString, IsUUID, IsOptional, IsBoolean, MaxLength, MinLength } from 'class-validator';
/**
* DTO para crear un nuevo certificado CSD
* Note: CertificateType enum removed per DDL alignment (fiscal schema has no certificate_type column)
*/
export class CreateCertificateDto {
@IsUUID()
tenantId: string;
@IsString()
@MinLength(12)
@MaxLength(13)
rfc: string;
@IsString()
@MaxLength(20)
certificateNumber: string;
@IsString()
certificatePem: string;
@IsString()
privateKeyPem: string;
@IsBoolean()
@IsOptional()
isDefault?: boolean;
@IsString()
@IsOptional()
@MaxLength(255)
description?: string;
@IsString()
@IsOptional()
notes?: string;
}
/**
* DTO para actualizar un certificado
*/
export class UpdateCertificateDto {
@IsBoolean()
@IsOptional()
isDefault?: boolean;
@IsString()
@IsOptional()
privateKeyPem?: string;
@IsString()
@IsOptional()
keyPassword?: string;
}
/**
* DTO para filtrar certificados
*/
export class CertificateFilterDto {
@IsUUID()
@IsOptional()
tenantId?: string;
@IsString()
@IsOptional()
rfc?: string;
@IsBoolean()
@IsOptional()
isActive?: boolean;
@IsBoolean()
@IsOptional()
isDefault?: boolean;
}
/**
* DTO de respuesta de certificado (sin datos sensibles)
*/
export class CertificateResponseDto {
id: string;
tenantId: string;
rfc: string;
certificateNumber: string;
serialNumber: string;
issuedAt: Date;
expiresAt: Date;
status: string;
issuerName: string | null;
subjectName: string | null;
description: string | null;
isDefault: boolean;
createdAt: Date;
updatedAt: Date | null;
}

View File

@ -0,0 +1,3 @@
export * from './create-certificate.dto.js';
export * from './stamp-invoice.dto.js';
export * from './cancel-invoice.dto.js';

View File

@ -0,0 +1,303 @@
import {
IsString,
IsUUID,
IsOptional,
IsNumber,
IsArray,
IsEnum,
IsDateString,
ValidateNested,
MaxLength,
MinLength,
Min,
IsObject,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CfdiType, CfdiRelationType, CfdiVersion } from '../enums/cfdi-type.enum.js';
/**
* DTO para concepto de CFDI
*/
export class CfdiConceptoDto {
@IsString()
@MaxLength(50)
claveProdServ: string;
@IsString()
@IsOptional()
@MaxLength(100)
noIdentificacion?: string;
@IsNumber()
@Min(0)
cantidad: number;
@IsString()
@MaxLength(20)
claveUnidad: string;
@IsString()
@IsOptional()
@MaxLength(20)
unidad?: string;
@IsString()
@MaxLength(1000)
descripcion: string;
@IsNumber()
@Min(0)
valorUnitario: number;
@IsNumber()
@Min(0)
importe: number;
@IsNumber()
@IsOptional()
@Min(0)
descuento?: number;
@IsString()
@IsOptional()
@MaxLength(5)
objetoImp?: string;
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CfdiImpuestoConceptoDto)
impuestos?: CfdiImpuestoConceptoDto[];
}
/**
* DTO para impuesto de concepto
*/
export class CfdiImpuestoConceptoDto {
@IsString()
@MaxLength(10)
impuesto: string;
@IsString()
@MaxLength(20)
tipoFactor: string;
@IsNumber()
@Min(0)
tasaOCuota: number;
@IsNumber()
@Min(0)
importe: number;
@IsNumber()
@IsOptional()
@Min(0)
base?: number;
}
/**
* DTO para emisor
*/
export class CfdiEmisorDto {
@IsString()
@MinLength(12)
@MaxLength(13)
rfc: string;
@IsString()
@MaxLength(300)
nombre: string;
@IsString()
@MaxLength(10)
regimenFiscal: string;
@IsString()
@IsOptional()
@MaxLength(5)
domicilioFiscal?: string;
}
/**
* DTO para receptor
*/
export class CfdiReceptorDto {
@IsString()
@MinLength(12)
@MaxLength(13)
rfc: string;
@IsString()
@MaxLength(300)
nombre: string;
@IsString()
@MaxLength(10)
usoCfdi: string;
@IsString()
@IsOptional()
@MaxLength(10)
regimenFiscalReceptor?: string;
@IsString()
@IsOptional()
@MaxLength(5)
domicilioFiscalReceptor?: string;
}
/**
* DTO principal para timbrar una factura
*/
export class StampInvoiceDto {
@IsUUID()
tenantId: string;
@IsUUID()
companyId: string;
@IsUUID()
@IsOptional()
invoiceId?: string;
@IsUUID()
@IsOptional()
certificateId?: string;
@IsEnum(CfdiVersion)
@IsOptional()
cfdiVersion?: CfdiVersion;
@IsEnum(CfdiType)
cfdiType: CfdiType;
@IsString()
@IsOptional()
@MaxLength(25)
serie?: string;
@IsString()
@IsOptional()
@MaxLength(40)
folio?: string;
@IsDateString()
@IsOptional()
fechaEmision?: string;
@ValidateNested()
@Type(() => CfdiEmisorDto)
emisor: CfdiEmisorDto;
@ValidateNested()
@Type(() => CfdiReceptorDto)
receptor: CfdiReceptorDto;
@IsArray()
@ValidateNested({ each: true })
@Type(() => CfdiConceptoDto)
conceptos: CfdiConceptoDto[];
@IsNumber()
@Min(0)
subtotal: number;
@IsNumber()
@IsOptional()
@Min(0)
descuento?: number;
@IsNumber()
@Min(0)
total: number;
@IsString()
@MaxLength(3)
moneda: string;
@IsNumber()
@IsOptional()
@Min(0)
tipoCambio?: number;
@IsString()
@MaxLength(10)
formaPago: string;
@IsString()
@MaxLength(10)
metodoPago: string;
@IsString()
@IsOptional()
@MaxLength(100)
condicionesPago?: string;
@IsString()
@MaxLength(5)
lugarExpedicion: string;
@IsEnum(CfdiRelationType)
@IsOptional()
tipoRelacion?: CfdiRelationType;
@IsArray()
@IsOptional()
@IsUUID('all', { each: true })
cfdiRelacionados?: string[];
@IsObject()
@IsOptional()
impuestos?: Record<string, any>;
@IsObject()
@IsOptional()
complementos?: Record<string, any>;
@IsObject()
@IsOptional()
metadata?: Record<string, any>;
}
/**
* DTO de respuesta de timbrado
*/
export class StampResponseDto {
success: boolean;
cfdiInvoiceId: string;
uuid?: string;
fechaTimbrado?: Date;
selloSat?: string;
noCertificadoSat?: string;
cadenaOriginal?: string;
xmlTimbrado?: string;
qrCodeBase64?: string;
errorCode?: string;
errorMessage?: string;
}
/**
* DTO para consultar estado de un CFDI
*/
export class CheckStatusDto {
@IsUUID()
tenantId: string;
@IsString()
@MinLength(12)
@MaxLength(13)
rfcEmisor: string;
@IsString()
@MinLength(12)
@MaxLength(13)
rfcReceptor: string;
@IsNumber()
@Min(0)
total: number;
@IsUUID()
uuid: string;
}

View File

@ -0,0 +1,141 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { CancellationReason } from '../enums/cancellation-reason.enum.js';
import { CfdiInvoice } from './cfdi-invoice.entity.js';
/**
* Cancellation request status aligned with DDL fiscal.cancellation_request_status
*/
export enum CancellationRequestStatus {
PENDING = 'pending',
SUBMITTED = 'submitted',
ACCEPTED = 'accepted',
REJECTED = 'rejected',
IN_PROCESS = 'in_process',
EXPIRED = 'expired',
CANCELLED = 'cancelled',
ERROR = 'error',
}
/**
* CFDI Cancellation Request Entity
* Aligned with DDL: fiscal.cfdi_cancellation_requests
*/
@Entity({ schema: 'fiscal', name: 'cfdi_cancellation_requests' })
@Index('idx_cfdi_cancellation_requests_tenant', ['tenantId'])
@Index('idx_cfdi_cancellation_requests_cfdi', ['cfdiInvoiceId'])
@Index('idx_cfdi_cancellation_requests_uuid', ['cfdiUuid'])
@Index('idx_cfdi_cancellation_requests_status', ['status'])
@Index('idx_cfdi_cancellation_requests_created', ['createdAt'])
export class CfdiCancellation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: false, name: 'cfdi_invoice_id' })
cfdiInvoiceId: string;
@Column({ type: 'varchar', length: 36, nullable: false, name: 'cfdi_uuid' })
cfdiUuid: string;
@Column({
type: 'enum',
enum: CancellationReason,
nullable: false,
name: 'cancellation_reason',
})
cancellationReason: CancellationReason;
@Column({ type: 'varchar', length: 36, nullable: true, name: 'substitute_uuid' })
substituteUuid: string | null;
@Column({ type: 'uuid', nullable: true, name: 'substitute_cfdi_id' })
substituteCfdiId: string | null;
@Column({
type: 'enum',
enum: CancellationRequestStatus,
default: CancellationRequestStatus.PENDING,
nullable: false,
})
status: CancellationRequestStatus;
@Column({ type: 'text', nullable: true, name: 'sat_ack_xml' })
satAckXml: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'completed_at' })
completedAt: Date | null;
@Column({ type: 'text', nullable: true, name: 'error_message' })
errorMessage: string | null;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'error_code' })
errorCode: string | null;
@Column({ type: 'jsonb', nullable: true, name: 'error_details' })
errorDetails: Record<string, any> | null;
@Column({ type: 'varchar', length: 10, nullable: true, name: 'sat_response_code' })
satResponseCode: string | null;
@Column({ type: 'text', nullable: true, name: 'sat_response_message' })
satResponseMessage: string | null;
@Column({ type: 'text', nullable: true, name: 'receiver_response_reason' })
receiverResponseReason: string | null;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'receiver_response' })
receiverResponse: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'receiver_response_at' })
receiverResponseAt: Date | null;
@Column({ type: 'timestamptz', nullable: true, name: 'expires_at' })
expiresAt: Date | null;
@Column({ type: 'timestamptz', nullable: true, name: 'requested_at' })
requestedAt: Date | null;
@Column({ type: 'timestamptz', nullable: true, name: 'submitted_at' })
submittedAt: Date | null;
@Column({ type: 'timestamptz', nullable: true, name: 'response_at' })
responseAt: Date | null;
@Column({ type: 'text', nullable: true, name: 'reason_notes' })
reasonNotes: string | null;
@Column({ type: 'text', nullable: true, name: 'internal_notes' })
internalNotes: string | null;
@Column({ type: 'integer', default: 0, nullable: false, name: 'retry_count' })
retryCount: number;
// Relation
@ManyToOne(() => CfdiInvoice, (invoice) => invoice.cancellations)
@JoinColumn({ name: 'cfdi_invoice_id' })
cfdiInvoice: CfdiInvoice;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
}

View File

@ -0,0 +1,100 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
/**
* Certificate status aligned with DDL fiscal.cfdi_certificate_status
*/
export enum CertificateStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
EXPIRED = 'expired',
REVOKED = 'revoked',
PENDING = 'pending',
}
/**
* CFDI Certificate Entity
* Aligned with DDL: fiscal.cfdi_certificates
*/
@Entity({ schema: 'fiscal', name: 'cfdi_certificates' })
@Index('idx_cfdi_certificates_tenant', ['tenantId'])
@Index('idx_cfdi_certificates_rfc', ['rfc'])
@Index('idx_cfdi_certificates_status', ['status'])
@Index('idx_cfdi_certificates_expires', ['expiresAt'])
@Index('idx_cfdi_certificates_default', ['isDefault'])
export class CfdiCertificate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'varchar', length: 13, nullable: false })
rfc: string;
@Column({ type: 'varchar', length: 20, nullable: false, name: 'certificate_number' })
certificateNumber: string;
@Column({ type: 'varchar', length: 100, nullable: false, unique: true, name: 'serial_number' })
serialNumber: string;
@Column({ type: 'text', nullable: false, name: 'certificate_pem' })
certificatePem: string;
@Column({ type: 'text', nullable: false, name: 'private_key_pem_encrypted' })
privateKeyPemEncrypted: string;
@Column({ type: 'timestamptz', nullable: false, name: 'issued_at' })
issuedAt: Date;
@Column({ type: 'timestamptz', nullable: false, name: 'expires_at' })
expiresAt: Date;
@Column({
type: 'enum',
enum: CertificateStatus,
default: CertificateStatus.PENDING,
nullable: false,
})
status: CertificateStatus;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'issuer_name' })
issuerName: string | null;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'subject_name' })
subjectName: string | null;
@Column({ type: 'varchar', length: 255, nullable: true, name: 'description' })
description: string | null;
@Column({ type: 'text', nullable: true, name: 'notes' })
notes: string | null;
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_default' })
isDefault: boolean;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -0,0 +1,259 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
OneToMany,
ManyToOne,
JoinColumn,
DeleteDateColumn,
} from 'typeorm';
import { CfdiStatus } from '../enums/cfdi-status.enum.js';
import { CfdiLog } from './cfdi-log.entity.js';
import { CfdiCancellation } from './cfdi-cancellation.entity.js';
/**
* Voucher type enum aligned with DDL fiscal.cfdi_voucher_type
*/
export enum CfdiVoucherType {
INGRESO = 'I',
EGRESO = 'E',
TRASLADO = 'T',
NOMINA = 'N',
PAGO = 'P',
}
/**
* CFDI Invoice Entity
* Aligned with DDL: fiscal.cfdi_invoices
*/
@Entity({ schema: 'fiscal', name: 'cfdi_invoices' })
@Index('idx_cfdi_invoices_tenant', ['tenantId'])
@Index('idx_cfdi_invoices_uuid', ['uuid'], { unique: true, where: 'uuid IS NOT NULL' })
@Index('idx_cfdi_invoices_invoice', ['invoiceId'])
@Index('idx_cfdi_invoices_status', ['status'])
@Index('idx_cfdi_invoices_issuer', ['issuerRfc'])
@Index('idx_cfdi_invoices_receiver', ['receiverRfc'])
@Index('idx_cfdi_invoices_folio', ['serie', 'folio'])
@Index('idx_cfdi_invoices_stamp_date', ['stampDate'])
export class CfdiInvoice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'invoice_id' })
invoiceId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'certificate_id' })
certificateId: string | null;
// CFDI Identity
@Column({ type: 'varchar', length: 36, nullable: true })
uuid: string | null;
@Column({ type: 'varchar', length: 25, nullable: true })
serie: string | null;
@Column({ type: 'varchar', length: 40, nullable: true })
folio: string | null;
@Column({ type: 'varchar', length: 5, default: '4.0', nullable: false, name: 'cfdi_version' })
cfdiVersion: string;
@Column({
type: 'enum',
enum: CfdiVoucherType,
default: CfdiVoucherType.INGRESO,
nullable: false,
name: 'voucher_type',
})
voucherType: CfdiVoucherType;
// Issuer (Emisor)
@Column({ type: 'varchar', length: 13, nullable: false, name: 'issuer_rfc' })
issuerRfc: string;
@Column({ type: 'varchar', length: 300, nullable: false, name: 'issuer_name' })
issuerName: string;
@Column({ type: 'varchar', length: 10, nullable: false, name: 'issuer_fiscal_regime' })
issuerFiscalRegime: string;
// Receiver (Receptor)
@Column({ type: 'varchar', length: 13, nullable: false, name: 'receiver_rfc' })
receiverRfc: string;
@Column({ type: 'varchar', length: 300, nullable: false, name: 'receiver_name' })
receiverName: string;
@Column({ type: 'varchar', length: 10, nullable: true, name: 'receiver_fiscal_regime' })
receiverFiscalRegime: string | null;
@Column({ type: 'varchar', length: 5, nullable: true, name: 'receiver_zip_code' })
receiverZipCode: string | null;
@Column({ type: 'varchar', length: 3, nullable: true, name: 'receiver_tax_residence' })
receiverTaxResidence: string | null;
@Column({ type: 'varchar', length: 40, nullable: true, name: 'receiver_tax_id' })
receiverTaxId: string | null;
@Column({ type: 'varchar', length: 10, nullable: false, name: 'cfdi_use' })
cfdiUse: string;
// Amounts
@Column({ type: 'decimal', precision: 18, scale: 6, default: 0, nullable: false, name: 'subtotal' })
subtotal: number;
@Column({ type: 'decimal', precision: 18, scale: 6, default: 0, nullable: true, name: 'discount' })
discount: number | null;
@Column({ type: 'decimal', precision: 18, scale: 6, default: 0, nullable: false, name: 'total' })
total: number;
@Column({ type: 'decimal', precision: 18, scale: 6, nullable: true, name: 'total_transferred_taxes' })
totalTransferredTaxes: number | null;
@Column({ type: 'decimal', precision: 18, scale: 6, nullable: true, name: 'total_withheld_taxes' })
totalWithheldTaxes: number | null;
@Column({ type: 'varchar', length: 3, default: 'MXN', nullable: false, name: 'currency' })
currency: string;
@Column({ type: 'decimal', precision: 10, scale: 6, default: 1, nullable: true, name: 'exchange_rate' })
exchangeRate: number | null;
// Exportation
@Column({ type: 'varchar', length: 2, default: '01', nullable: true, name: 'exportation' })
exportation: string | null;
// Payment info
@Column({ type: 'varchar', length: 10, nullable: false, name: 'payment_form' })
paymentForm: string;
@Column({ type: 'varchar', length: 10, nullable: false, name: 'payment_method' })
paymentMethod: string;
@Column({ type: 'varchar', length: 1000, nullable: true, name: 'payment_conditions' })
paymentConditions: string | null;
// Location
@Column({ type: 'varchar', length: 5, nullable: false, name: 'expedition_place' })
expeditionPlace: string;
// Confirmation
@Column({ type: 'varchar', length: 17, nullable: true, name: 'confirmation_code' })
confirmationCode: string | null;
// Related CFDI
@Column({ type: 'varchar', length: 10, nullable: true, name: 'related_cfdi_type' })
relatedCfdiType: string | null;
// Global info (for global invoices)
@Column({ type: 'varchar', length: 10, nullable: true, name: 'global_info_periodicity' })
globalInfoPeriodicity: string | null;
@Column({ type: 'varchar', length: 10, nullable: true, name: 'global_info_months' })
globalInfoMonths: string | null;
@Column({ type: 'varchar', length: 4, nullable: true, name: 'global_info_year' })
globalInfoYear: string | null;
// Status
@Column({
type: 'enum',
enum: CfdiStatus,
default: CfdiStatus.DRAFT,
nullable: false,
})
status: CfdiStatus;
@Column({ type: 'text', nullable: true, name: 'last_error' })
lastError: string | null;
@Column({ type: 'jsonb', nullable: true, name: 'error_details' })
errorDetails: Record<string, any> | null;
// Stamps
@Column({ type: 'timestamptz', nullable: true, name: 'stamp_date' })
stampDate: Date | null;
@Column({ type: 'text', nullable: true, name: 'stamp_cfdi_seal' })
stampCfdiSeal: string | null;
@Column({ type: 'text', nullable: true, name: 'stamp_sat_seal' })
stampSatSeal: string | null;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'sat_certificate_number' })
satCertificateNumber: string | null;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'certificate_number' })
certificateNumber: string | null;
@Column({ type: 'text', nullable: true, name: 'stamp_original_chain' })
stampOriginalChain: string | null;
// XML Storage
@Column({ type: 'text', nullable: true, name: 'xml_original' })
xmlOriginal: string | null;
@Column({ type: 'text', nullable: true, name: 'xml_stamped' })
xmlStamped: string | null;
// PDF
@Column({ type: 'varchar', length: 500, nullable: true, name: 'pdf_url' })
pdfUrl: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'pdf_generated_at' })
pdfGeneratedAt: Date | null;
// Cancellation
@Column({ type: 'varchar', length: 50, nullable: true, name: 'cancellation_status' })
cancellationStatus: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'cancellation_date' })
cancellationDate: Date | null;
@Column({ type: 'text', nullable: true, name: 'cancellation_ack_xml' })
cancellationAckXml: string | null;
// SAT Validation
@Column({ type: 'timestamptz', nullable: true, name: 'last_sat_validation' })
lastSatValidation: Date | null;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'sat_validation_status' })
satValidationStatus: string | null;
@Column({ type: 'jsonb', nullable: true, name: 'sat_validation_response' })
satValidationResponse: Record<string, any> | null;
// Relations
@OneToMany(() => CfdiLog, (log) => log.cfdiInvoice, { cascade: true })
logs: CfdiLog[];
@OneToMany(() => CfdiCancellation, (cancellation) => cancellation.cfdiInvoice)
cancellations: CfdiCancellation[];
// Audit fields
@Column({ type: 'timestamptz', nullable: true, name: 'stamped_at' })
stampedAt: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
}

View File

@ -0,0 +1,109 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { CfdiInvoice } from './cfdi-invoice.entity.js';
/**
* CFDI operation type aligned with DDL fiscal.cfdi_operation_type
*/
export enum CfdiOperationType {
CREATE = 'create',
STAMP = 'stamp',
STAMP_RETRY = 'stamp_retry',
CANCEL_REQUEST = 'cancel_request',
CANCEL_ACCEPT = 'cancel_accept',
CANCEL_REJECT = 'cancel_reject',
CANCEL_COMPLETE = 'cancel_complete',
VALIDATE = 'validate',
DOWNLOAD = 'download',
EMAIL = 'email',
ERROR = 'error',
}
/**
* CFDI Operation Log Entity
* Aligned with DDL: fiscal.cfdi_operation_logs
*/
@Entity({ schema: 'fiscal', name: 'cfdi_operation_logs' })
@Index('idx_cfdi_operation_logs_tenant', ['tenantId'])
@Index('idx_cfdi_operation_logs_cfdi', ['cfdiInvoiceId'])
@Index('idx_cfdi_operation_logs_operation', ['operationType'])
@Index('idx_cfdi_operation_logs_created', ['createdAt'])
export class CfdiLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'cfdi_invoice_id' })
cfdiInvoiceId: string | null;
@Column({ type: 'varchar', length: 36, nullable: true, name: 'cfdi_uuid' })
cfdiUuid: string | null;
@Column({
type: 'enum',
enum: CfdiOperationType,
nullable: false,
name: 'operation_type',
})
operationType: CfdiOperationType;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'status_before' })
statusBefore: string | null;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'status_after' })
statusAfter: string | null;
@Column({ type: 'boolean', default: false, nullable: false, name: 'success' })
success: boolean;
@Column({ type: 'varchar', length: 100, nullable: true, name: 'error_code' })
errorCode: string | null;
@Column({ type: 'text', nullable: true, name: 'error_message' })
errorMessage: string | null;
@Column({ type: 'jsonb', nullable: true, name: 'error_details' })
errorDetails: Record<string, any> | null;
@Column({ type: 'jsonb', nullable: true, name: 'request_payload' })
requestPayload: Record<string, any> | null;
@Column({ type: 'jsonb', nullable: true, name: 'response_payload' })
responsePayload: Record<string, any> | null;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'pac_code' })
pacCode: string | null;
@Column({ type: 'varchar', length: 100, nullable: true, name: 'pac_transaction_id' })
pacTransactionId: string | null;
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
ipAddress: string | null;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'user_agent' })
userAgent: string | null;
@Column({ type: 'integer', nullable: true, name: 'duration_ms' })
durationMs: number | null;
// Relation
@ManyToOne(() => CfdiInvoice, (invoice) => invoice.logs, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'cfdi_invoice_id' })
cfdiInvoice: CfdiInvoice | null;
// Audit
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
}

View File

@ -0,0 +1,98 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
DeleteDateColumn,
} from 'typeorm';
/**
* PAC environment type aligned with DDL fiscal.cfdi_pac_configurations.environment
*/
export type PacEnvironment = 'sandbox' | 'production';
/**
* CFDI PAC Configuration Entity
* Aligned with DDL: fiscal.cfdi_pac_configurations (27-cfdi-core.sql)
*
* Stores connection configuration for Proveedores Autorizados de Certificacion (PAC)
* used for CFDI stamping (timbrado).
*/
@Entity({ schema: 'fiscal', name: 'cfdi_pac_configurations' })
@Unique(['tenantId', 'pacCode'])
@Index('idx_cfdi_pac_tenant', ['tenantId'])
@Index('idx_cfdi_pac_code', ['pacCode'])
export class CfdiPacConfiguration {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
// PAC identification
@Column({ type: 'varchar', length: 20, nullable: false, name: 'pac_code' })
pacCode: string;
@Column({ type: 'varchar', length: 100, nullable: false, name: 'pac_name' })
pacName: string;
// Credentials (encrypted)
@Column({ type: 'varchar', length: 255, nullable: true, name: 'username' })
username: string | null;
@Column({ type: 'text', nullable: true, name: 'password_encrypted' })
passwordEncrypted: string | null;
@Column({ type: 'text', nullable: true, name: 'api_key_encrypted' })
apiKeyEncrypted: string | null;
// Endpoints
@Column({ type: 'varchar', length: 500, nullable: true, name: 'production_url' })
productionUrl: string | null;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'sandbox_url' })
sandboxUrl: string | null;
// Configuration
@Column({ type: 'varchar', length: 20, default: 'sandbox', name: 'environment' })
environment: PacEnvironment;
@Column({ type: 'boolean', default: true, name: 'is_active' })
isActive: boolean;
@Column({ type: 'boolean', default: false, name: 'is_default' })
isDefault: boolean;
// Contract
@Column({ type: 'varchar', length: 50, nullable: true, name: 'contract_number' })
contractNumber: string | null;
@Column({ type: 'date', nullable: true, name: 'contract_expires_at' })
contractExpiresAt: Date | null;
// Limits
@Column({ type: 'integer', nullable: true, name: 'monthly_stamp_limit' })
monthlyStampLimit: number | null;
@Column({ type: 'integer', default: 0, name: 'stamps_used_this_month' })
stampsUsedThisMonth: number;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
}

View File

@ -0,0 +1,183 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
DeleteDateColumn,
} from 'typeorm';
/**
* Payment complement status aligned with DDL fiscal.payment_complement_status
*/
export enum PaymentComplementStatus {
DRAFT = 'draft',
PENDING = 'pending',
PROCESSING = 'processing',
STAMPED = 'stamped',
ERROR = 'error',
CANCELLED = 'cancelled',
CANCELLATION_PENDING = 'cancellation_pending',
}
/**
* CFDI Payment Complement Entity
* Aligned with DDL: fiscal.cfdi_payment_complements
*/
@Entity({ schema: 'fiscal', name: 'cfdi_payment_complements' })
@Index('idx_cfdi_payment_complements_tenant', ['tenantId'])
@Index('idx_cfdi_payment_complements_uuid', ['uuid'], { unique: true, where: 'uuid IS NOT NULL' })
@Index('idx_cfdi_payment_complements_payment', ['paymentId'])
@Index('idx_cfdi_payment_complements_status', ['status'])
@Index('idx_cfdi_payment_complements_created', ['createdAt'])
export class CfdiPaymentComplement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'payment_id' })
paymentId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'certificate_id' })
certificateId: string | null;
// CFDI Identity
@Column({ type: 'varchar', length: 36, nullable: true })
uuid: string | null;
@Column({ type: 'varchar', length: 25, nullable: true })
serie: string | null;
@Column({ type: 'varchar', length: 40, nullable: true })
folio: string | null;
// Issuer (Emisor)
@Column({ type: 'varchar', length: 13, nullable: false, name: 'issuer_rfc' })
issuerRfc: string;
@Column({ type: 'varchar', length: 300, nullable: false, name: 'issuer_name' })
issuerName: string;
@Column({ type: 'varchar', length: 10, nullable: false, name: 'issuer_fiscal_regime' })
issuerFiscalRegime: string;
// Receiver (Receptor)
@Column({ type: 'varchar', length: 13, nullable: false, name: 'receiver_rfc' })
receiverRfc: string;
@Column({ type: 'varchar', length: 300, nullable: false, name: 'receiver_name' })
receiverName: string;
@Column({ type: 'varchar', length: 10, nullable: true, name: 'receiver_fiscal_regime' })
receiverFiscalRegime: string | null;
@Column({ type: 'varchar', length: 5, nullable: true, name: 'receiver_zip_code' })
receiverZipCode: string | null;
// Expedition place
@Column({ type: 'varchar', length: 5, nullable: false, name: 'expedition_place' })
expeditionPlace: string;
// Payment details
@Column({ type: 'date', nullable: false, name: 'payment_date' })
paymentDate: Date;
@Column({ type: 'varchar', length: 10, nullable: false, name: 'payment_form' })
paymentForm: string;
@Column({ type: 'varchar', length: 3, nullable: false, name: 'currency' })
currency: string;
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true, name: 'exchange_rate' })
exchangeRate: number | null;
@Column({ type: 'decimal', precision: 18, scale: 6, nullable: false, name: 'total_amount' })
totalAmount: number;
@Column({ type: 'varchar', length: 100, nullable: true, name: 'operation_number' })
operationNumber: string | null;
// Bank info
@Column({ type: 'varchar', length: 13, nullable: true, name: 'payer_bank_rfc' })
payerBankRfc: string | null;
@Column({ type: 'varchar', length: 300, nullable: true, name: 'payer_bank_name' })
payerBankName: string | null;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'payer_account' })
payerAccount: string | null;
@Column({ type: 'varchar', length: 13, nullable: true, name: 'beneficiary_bank_rfc' })
beneficiaryBankRfc: string | null;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'beneficiary_account' })
beneficiaryAccount: string | null;
// Certificate
@Column({ type: 'varchar', length: 20, nullable: true, name: 'certificate_number' })
certificateNumber: string | null;
// Status
@Column({
type: 'enum',
enum: PaymentComplementStatus,
default: PaymentComplementStatus.DRAFT,
nullable: false,
})
status: PaymentComplementStatus;
@Column({ type: 'text', nullable: true, name: 'error_message' })
errorMessage: string | null;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'error_code' })
errorCode: string | null;
// Stamps
@Column({ type: 'timestamptz', nullable: true, name: 'stamp_date' })
stampDate: Date | null;
@Column({ type: 'text', nullable: true, name: 'stamp_cfdi_seal' })
stampCfdiSeal: string | null;
@Column({ type: 'text', nullable: true, name: 'stamp_sat_seal' })
stampSatSeal: string | null;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'sat_certificate_number' })
satCertificateNumber: string | null;
@Column({ type: 'text', nullable: true, name: 'stamp_original_chain' })
stampOriginalChain: string | null;
// XML Storage
@Column({ type: 'text', nullable: true, name: 'xml_original' })
xmlOriginal: string | null;
@Column({ type: 'text', nullable: true, name: 'xml_stamped' })
xmlStamped: string | null;
// Cancellation reference
@Column({ type: 'uuid', nullable: true, name: 'cancellation_request_id' })
cancellationRequestId: string | null;
// Audit fields
@Column({ type: 'timestamptz', nullable: true, name: 'stamped_at' })
stampedAt: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
}

View File

@ -0,0 +1,82 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
/**
* Queue status type aligned with DDL fiscal.cfdi_stamp_queue.queue_status
*/
export type QueueStatus = 'pending' | 'processing' | 'completed' | 'failed';
/**
* Stamp document type aligned with DDL fiscal.cfdi_stamp_queue.document_type
*/
export type StampDocumentType = 'invoice' | 'payment_complement';
/**
* CFDI Stamp Queue Entity
* Aligned with DDL: fiscal.cfdi_stamp_queue (28-cfdi-operations.sql)
*
* Asynchronous queue for CFDI stamping (timbrado). Documents are queued
* for processing by a background worker that handles PAC communication.
*/
@Entity({ schema: 'fiscal', name: 'cfdi_stamp_queue' })
@Index('idx_cfdi_queue_tenant', ['tenantId'])
@Index('idx_cfdi_queue_status', ['queueStatus'])
@Index('idx_cfdi_queue_doc', ['documentType', 'documentId'])
export class CfdiStampQueue {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
// Document reference
@Column({ type: 'varchar', length: 20, nullable: false, name: 'document_type' })
documentType: StampDocumentType;
@Column({ type: 'uuid', nullable: false, name: 'document_id' })
documentId: string;
// Priority (1=urgent, 5=normal, 10=low)
@Column({ type: 'integer', default: 5, name: 'priority' })
priority: number;
// Queue status
@Column({ type: 'varchar', length: 20, nullable: false, default: 'pending', name: 'queue_status' })
queueStatus: QueueStatus;
// Retry logic
@Column({ type: 'integer', default: 0, name: 'attempts' })
attempts: number;
@Column({ type: 'integer', default: 3, name: 'max_attempts' })
maxAttempts: number;
@Column({ type: 'timestamptz', nullable: true, name: 'next_retry_at' })
nextRetryAt: Date | null;
// Result
@Column({ type: 'timestamptz', nullable: true, name: 'completed_at' })
completedAt: Date | null;
@Column({ type: 'varchar', length: 36, nullable: true, name: 'result_cfdi_uuid' })
resultCfdiUuid: string | null;
@Column({ type: 'text', nullable: true, name: 'result_error' })
resultError: string | null;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
}

View File

@ -0,0 +1,7 @@
export * from './cfdi-certificate.entity.js';
export * from './cfdi-invoice.entity.js';
export * from './cfdi-cancellation.entity.js';
export * from './cfdi-log.entity.js';
export * from './cfdi-payment-complement.entity.js';
export * from './cfdi-pac-configuration.entity.js';
export * from './cfdi-stamp-queue.entity.js';

View File

@ -0,0 +1,28 @@
/**
* Cancellation Reason Enum
* Motivos de cancelación según catálogo SAT
*/
export enum CancellationReason {
/** 01 - Comprobante emitido con errores con relación */
ERROR_WITH_RELATION = '01',
/** 02 - Comprobante emitido con errores sin relación */
ERROR_WITHOUT_RELATION = '02',
/** 03 - No se llevó a cabo la operación */
OPERATION_NOT_PERFORMED = '03',
/** 04 - Operación nominativa relacionada en la factura global */
NOMINATIVE_OPERATION = '04',
}
/**
* Descripciones de los motivos de cancelación
*/
export const CancellationReasonDescriptions: Record<CancellationReason, string> = {
[CancellationReason.ERROR_WITH_RELATION]: 'Comprobante emitido con errores con relación',
[CancellationReason.ERROR_WITHOUT_RELATION]: 'Comprobante emitido con errores sin relación',
[CancellationReason.OPERATION_NOT_PERFORMED]: 'No se llevó a cabo la operación',
[CancellationReason.NOMINATIVE_OPERATION]: 'Operación nominativa relacionada en la factura global',
};

View File

@ -0,0 +1,30 @@
/**
* CFDI Status Enum
* Estados posibles de un CFDI en su ciclo de vida
*/
export enum CfdiStatus {
/** Borrador - CFDI creado pero no enviado a timbrar */
DRAFT = 'draft',
/** Pendiente - CFDI en cola para timbrado */
PENDING = 'pending',
/** Procesando - CFDI siendo procesado por el PAC */
PROCESSING = 'processing',
/** Timbrado - CFDI timbrado exitosamente */
STAMPED = 'stamped',
/** Error - Error en el proceso de timbrado */
ERROR = 'error',
/** Cancelado - CFDI cancelado ante el SAT */
CANCELLED = 'cancelled',
/** Cancelacion pendiente - Solicitud de cancelacion en proceso */
CANCELLATION_PENDING = 'cancellation_pending',
/** Cancelacion rechazada - El receptor rechazó la cancelación */
CANCELLATION_REJECTED = 'cancellation_rejected',
}

View File

@ -0,0 +1,72 @@
/**
* CFDI Type Enum
* Tipos de comprobante CFDI según catálogo SAT
*/
export enum CfdiType {
/** I - Ingreso */
INCOME = 'I',
/** E - Egreso (Nota de crédito) */
EXPENSE = 'E',
/** T - Traslado */
TRANSFER = 'T',
/** N - Nómina */
PAYROLL = 'N',
/** P - Pago (Complemento de pago) */
PAYMENT = 'P',
}
/**
* Descripciones de los tipos de CFDI
*/
export const CfdiTypeDescriptions: Record<CfdiType, string> = {
[CfdiType.INCOME]: 'Ingreso',
[CfdiType.EXPENSE]: 'Egreso',
[CfdiType.TRANSFER]: 'Traslado',
[CfdiType.PAYROLL]: 'Nómina',
[CfdiType.PAYMENT]: 'Pago',
};
/**
* Tipo de relación CFDI según catálogo SAT
*/
export enum CfdiRelationType {
/** 01 - Nota de crédito de los documentos relacionados */
CREDIT_NOTE = '01',
/** 02 - Nota de débito de los documentos relacionados */
DEBIT_NOTE = '02',
/** 03 - Devolución de mercancía sobre facturas o traslados previos */
MERCHANDISE_RETURN = '03',
/** 04 - Sustitución de los CFDI previos */
SUBSTITUTION = '04',
/** 05 - Traslados de mercancías facturadas previamente */
TRANSFER_OF_INVOICED = '05',
/** 06 - Factura generada por los traslados previos */
INVOICE_FROM_TRANSFER = '06',
/** 07 - CFDI por aplicación de anticipo */
ADVANCE_APPLICATION = '07',
/** 08 - Factura generada por pagos en parcialidades */
PARTIAL_PAYMENTS = '08',
/** 09 - Factura generada por pagos diferidos */
DEFERRED_PAYMENTS = '09',
}
/**
* Versión del CFDI
*/
export enum CfdiVersion {
V3_3 = '3.3',
V4_0 = '4.0',
}

View File

@ -0,0 +1,3 @@
export * from './cfdi-status.enum.js';
export * from './cancellation-reason.enum.js';
export * from './cfdi-type.enum.js';

View File

@ -0,0 +1,188 @@
/**
* Finkok Response Interfaces
* Interfaces para respuestas del PAC Finkok
*/
/**
* Respuesta base de Finkok
*/
export interface FinkokBaseResponse {
/** Código de resultado */
CodEstatus?: string;
/** Mensaje de resultado */
Mensaje?: string;
/** Errores si los hay */
Incidencias?: FinkokIncidencia[];
}
/**
* Incidencia/Error de Finkok
*/
export interface FinkokIncidencia {
/** Código del error */
CodigoError: string;
/** Mensaje de error */
MensajeIncidencia: string;
/** Información adicional */
ExtraInfo?: string;
/** Nodo donde ocurrió el error */
NodoNoOk?: string;
}
/**
* Respuesta de timbrado (stamp)
*/
export interface FinkokStampResponse extends FinkokBaseResponse {
/** UUID del CFDI timbrado */
UUID?: string;
/** Fecha de timbrado */
Fecha?: string;
/** Sello del SAT */
SatSeal?: string;
/** Número de certificado del SAT */
NoCertificadoSAT?: string;
/** XML timbrado completo */
xml?: string;
/** Cadena original del timbre */
CadenaOriginal?: string;
/** QR code en base64 */
QrCode?: string;
}
/**
* Respuesta de cancelación
*/
export interface FinkokCancelResponse extends FinkokBaseResponse {
/** Acuse de cancelación */
Acuse?: string;
/** Fecha de cancelación */
Fecha?: string;
/** Estado de la cancelación */
EstatusCancelacion?: string;
/** UUID cancelado */
UUID?: string;
/** RFC del emisor */
RfcEmisor?: string;
/** Folios procesados */
Folios?: FinkokFolioCancelacion[];
}
/**
* Información de folio en cancelación
*/
export interface FinkokFolioCancelacion {
/** UUID del folio */
UUID: string;
/** Estatus del folio */
EstatusUUID: string;
/** RFC del emisor */
RfcEmisor?: string;
}
/**
* Respuesta de consulta de estado
*/
export interface FinkokStatusResponse extends FinkokBaseResponse {
/** Estado del CFDI */
Estado?: string;
/** Es cancelable */
EsCancelable?: string;
/** Estado de cancelación */
EstatusCancelacion?: string;
/** Validación EFOS */
ValidacionEFOS?: string;
}
/**
* Respuesta de consulta de créditos/timbres
*/
export interface FinkokCreditsResponse {
/** Créditos disponibles */
credit?: number;
/** Fecha de consulta */
date?: string;
}
/**
* Respuesta de aceptación/rechazo de cancelación
*/
export interface FinkokCancelAnswerResponse extends FinkokBaseResponse {
/** UUID del CFDI */
UUID?: string;
/** RFC del receptor */
RfcReceptor?: string;
/** Respuesta (Aceptacion/Rechazo) */
Respuesta?: 'Aceptacion' | 'Rechazo';
/** Acuse de la respuesta */
Acuse?: string;
}
/**
* Solicitud de cancelación pendiente
*/
export interface FinkokPendingCancellation {
/** UUID del CFDI */
UUID: string;
/** RFC del emisor */
RfcEmisor: string;
/** RFC del receptor */
RfcReceptor: string;
/** Fecha de solicitud */
Fecha: string;
/** Total del CFDI */
Total: number;
}
/**
* Respuesta de lista de cancelaciones pendientes
*/
export interface FinkokPendingListResponse extends FinkokBaseResponse {
/** Lista de cancelaciones pendientes */
Cancelaciones?: FinkokPendingCancellation[];
}
/**
* Configuración del cliente Finkok
*/
export interface FinkokClientConfig {
/** Usuario de Finkok */
username: string;
/** Contraseña de Finkok */
password: string;
/** Usar ambiente de producción */
production: boolean;
/** Timeout en milisegundos */
timeout?: number;
/** URL del servicio (override) */
serviceUrl?: string;
}
/**
* Datos del emisor para timbrado
*/
export interface FinkokEmisorData {
/** RFC del emisor */
rfc: string;
/** Nombre/Razón social */
nombre: string;
/** Código de régimen fiscal */
regimenFiscal: string;
/** Código postal del domicilio fiscal */
domicilioFiscal?: string;
}
/**
* Datos del receptor para timbrado
*/
export interface FinkokReceptorData {
/** RFC del receptor */
rfc: string;
/** Nombre/Razón social */
nombre: string;
/** Código de uso CFDI */
usoCfdi: string;
/** Código de régimen fiscal del receptor */
regimenFiscalReceptor?: string;
/** Código postal del domicilio fiscal */
domicilioFiscalReceptor?: string;
}

View File

@ -0,0 +1 @@
export * from './finkok-response.interface.js';

View File

@ -0,0 +1,827 @@
import { Repository, IsNull } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm.js';
import { CfdiInvoice } from '../entities/cfdi-invoice.entity.js';
import { CfdiCertificate, CertificateStatus } from '../entities/cfdi-certificate.entity.js';
import { CfdiCancellation, CancellationRequestStatus } from '../entities/cfdi-cancellation.entity.js';
import { CfdiLog, CfdiOperationType } from '../entities/cfdi-log.entity.js';
import { CfdiStampQueue } from '../entities/cfdi-stamp-queue.entity.js';
import { CfdiStatus } from '../enums/cfdi-status.enum.js';
import { CancellationReason } from '../enums/cancellation-reason.enum.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ===== Interfaces =====
export interface CreateCfdiInvoiceDto {
invoiceId?: string;
certificateId?: string;
serie?: string;
folio?: string;
cfdiVersion?: string;
voucherType?: string;
issuerRfc: string;
issuerName: string;
issuerFiscalRegime: string;
receiverRfc: string;
receiverName: string;
receiverFiscalRegime?: string;
receiverZipCode?: string;
receiverTaxResidence?: string;
receiverTaxId?: string;
cfdiUse: string;
subtotal: number;
discount?: number;
total: number;
totalTransferredTaxes?: number;
totalWithheldTaxes?: number;
currency?: string;
exchangeRate?: number;
exportation?: string;
paymentForm: string;
paymentMethod: string;
paymentConditions?: string;
expeditionPlace: string;
confirmationCode?: string;
relatedCfdiType?: string;
globalInfoPeriodicity?: string;
globalInfoMonths?: string;
globalInfoYear?: string;
}
export interface UpdateCfdiInvoiceDto {
invoiceId?: string | null;
certificateId?: string | null;
serie?: string | null;
folio?: string | null;
voucherType?: string;
issuerRfc?: string;
issuerName?: string;
issuerFiscalRegime?: string;
receiverRfc?: string;
receiverName?: string;
receiverFiscalRegime?: string | null;
receiverZipCode?: string | null;
receiverTaxResidence?: string | null;
receiverTaxId?: string | null;
cfdiUse?: string;
subtotal?: number;
discount?: number | null;
total?: number;
totalTransferredTaxes?: number | null;
totalWithheldTaxes?: number | null;
currency?: string;
exchangeRate?: number | null;
exportation?: string | null;
paymentForm?: string;
paymentMethod?: string;
paymentConditions?: string | null;
expeditionPlace?: string;
confirmationCode?: string | null;
relatedCfdiType?: string | null;
globalInfoPeriodicity?: string | null;
globalInfoMonths?: string | null;
globalInfoYear?: string | null;
}
export interface CfdiFilters {
search?: string;
status?: CfdiStatus;
voucherType?: string;
issuerRfc?: string;
receiverRfc?: string;
serie?: string;
folio?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
limit?: number;
}
export interface CertificateFilters {
rfc?: string;
status?: CertificateStatus;
isDefault?: boolean;
page?: number;
limit?: number;
}
export interface UploadCertificateDto {
rfc: string;
certificateNumber: string;
certificatePem: string;
privateKeyPemEncrypted: string;
issuedAt: string;
expiresAt: string;
serialNumber: string;
isDefault?: boolean;
issuerName?: string;
subjectName?: string;
description?: string;
notes?: string;
}
export interface CancellationRequestDto {
cancellationReason: CancellationReason;
substituteUuid?: string;
substituteCfdiId?: string;
reasonNotes?: string;
internalNotes?: string;
}
export interface CfdiStatusResponse {
id: string;
status: CfdiStatus;
uuid: string | null;
stampDate: Date | null;
cancellationStatus: string | null;
cancellationDate: Date | null;
lastSatValidation: Date | null;
satValidationStatus: string | null;
satValidationResponse: Record<string, any> | null;
lastError: string | null;
}
// ===== CfdiService Class =====
class CfdiService {
private invoiceRepository: Repository<CfdiInvoice>;
private certificateRepository: Repository<CfdiCertificate>;
private cancellationRepository: Repository<CfdiCancellation>;
private logRepository: Repository<CfdiLog>;
private stampQueueRepository: Repository<CfdiStampQueue>;
constructor() {
this.invoiceRepository = AppDataSource.getRepository(CfdiInvoice);
this.certificateRepository = AppDataSource.getRepository(CfdiCertificate);
this.cancellationRepository = AppDataSource.getRepository(CfdiCancellation);
this.logRepository = AppDataSource.getRepository(CfdiLog);
this.stampQueueRepository = AppDataSource.getRepository(CfdiStampQueue);
}
// ===== CFDI Invoice Operations =====
/**
* Get all CFDI invoices for a tenant with filters and pagination
*/
async findAll(
tenantId: string,
filters: CfdiFilters = {}
): Promise<{ data: CfdiInvoice[]; total: number }> {
try {
const {
search,
status,
voucherType,
issuerRfc,
receiverRfc,
serie,
folio,
dateFrom,
dateTo,
page = 1,
limit = 20,
} = filters;
const skip = (page - 1) * limit;
const queryBuilder = this.invoiceRepository
.createQueryBuilder('cfdi')
.where('cfdi.tenantId = :tenantId', { tenantId })
.andWhere('cfdi.deletedAt IS NULL');
// Apply search filter across key fields
if (search) {
queryBuilder.andWhere(
'(cfdi.uuid ILIKE :search OR cfdi.serie ILIKE :search OR cfdi.folio ILIKE :search OR cfdi.issuerRfc ILIKE :search OR cfdi.receiverRfc ILIKE :search OR cfdi.issuerName ILIKE :search OR cfdi.receiverName ILIKE :search)',
{ search: `%${search}%` }
);
}
if (status !== undefined) {
queryBuilder.andWhere('cfdi.status = :status', { status });
}
if (voucherType !== undefined) {
queryBuilder.andWhere('cfdi.voucherType = :voucherType', { voucherType });
}
if (issuerRfc) {
queryBuilder.andWhere('cfdi.issuerRfc = :issuerRfc', { issuerRfc });
}
if (receiverRfc) {
queryBuilder.andWhere('cfdi.receiverRfc = :receiverRfc', { receiverRfc });
}
if (serie) {
queryBuilder.andWhere('cfdi.serie = :serie', { serie });
}
if (folio) {
queryBuilder.andWhere('cfdi.folio = :folio', { folio });
}
if (dateFrom) {
queryBuilder.andWhere('cfdi.createdAt >= :dateFrom', { dateFrom });
}
if (dateTo) {
queryBuilder.andWhere('cfdi.createdAt <= :dateTo', { dateTo });
}
const total = await queryBuilder.getCount();
const data = await queryBuilder
.orderBy('cfdi.createdAt', 'DESC')
.skip(skip)
.take(limit)
.getMany();
logger.debug('CFDI invoices retrieved', { tenantId, count: data.length, total });
return { data, total };
} catch (error) {
logger.error('Error retrieving CFDI invoices', {
error: (error as Error).message,
tenantId,
});
throw error;
}
}
/**
* Get CFDI invoice by ID
*/
async findById(tenantId: string, id: string): Promise<CfdiInvoice> {
try {
const invoice = await this.invoiceRepository.findOne({
where: {
id,
tenantId,
deletedAt: IsNull(),
},
relations: ['logs', 'cancellations'],
});
if (!invoice) {
throw new NotFoundError('CFDI no encontrado');
}
return invoice;
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Error finding CFDI invoice', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Create a new CFDI invoice in draft status
*/
async create(
tenantId: string,
dto: CreateCfdiInvoiceDto,
userId: string
): Promise<CfdiInvoice> {
try {
const invoiceData: Partial<CfdiInvoice> = {
tenantId,
status: CfdiStatus.DRAFT,
cfdiVersion: dto.cfdiVersion || '4.0',
issuerRfc: dto.issuerRfc,
issuerName: dto.issuerName,
issuerFiscalRegime: dto.issuerFiscalRegime,
receiverRfc: dto.receiverRfc,
receiverName: dto.receiverName,
cfdiUse: dto.cfdiUse,
subtotal: dto.subtotal,
total: dto.total,
currency: dto.currency || 'MXN',
paymentForm: dto.paymentForm,
paymentMethod: dto.paymentMethod,
expeditionPlace: dto.expeditionPlace,
createdBy: userId,
};
// Add optional fields only if provided
if (dto.invoiceId !== undefined) invoiceData.invoiceId = dto.invoiceId;
if (dto.certificateId !== undefined) invoiceData.certificateId = dto.certificateId;
if (dto.serie !== undefined) invoiceData.serie = dto.serie;
if (dto.folio !== undefined) invoiceData.folio = dto.folio;
if (dto.voucherType !== undefined) invoiceData.voucherType = dto.voucherType as any;
if (dto.receiverFiscalRegime !== undefined) invoiceData.receiverFiscalRegime = dto.receiverFiscalRegime;
if (dto.receiverZipCode !== undefined) invoiceData.receiverZipCode = dto.receiverZipCode;
if (dto.receiverTaxResidence !== undefined) invoiceData.receiverTaxResidence = dto.receiverTaxResidence;
if (dto.receiverTaxId !== undefined) invoiceData.receiverTaxId = dto.receiverTaxId;
if (dto.discount !== undefined) invoiceData.discount = dto.discount;
if (dto.totalTransferredTaxes !== undefined) invoiceData.totalTransferredTaxes = dto.totalTransferredTaxes;
if (dto.totalWithheldTaxes !== undefined) invoiceData.totalWithheldTaxes = dto.totalWithheldTaxes;
if (dto.exchangeRate !== undefined) invoiceData.exchangeRate = dto.exchangeRate;
if (dto.exportation !== undefined) invoiceData.exportation = dto.exportation;
if (dto.paymentConditions !== undefined) invoiceData.paymentConditions = dto.paymentConditions;
if (dto.confirmationCode !== undefined) invoiceData.confirmationCode = dto.confirmationCode;
if (dto.relatedCfdiType !== undefined) invoiceData.relatedCfdiType = dto.relatedCfdiType;
if (dto.globalInfoPeriodicity !== undefined) invoiceData.globalInfoPeriodicity = dto.globalInfoPeriodicity;
if (dto.globalInfoMonths !== undefined) invoiceData.globalInfoMonths = dto.globalInfoMonths;
if (dto.globalInfoYear !== undefined) invoiceData.globalInfoYear = dto.globalInfoYear;
const invoice = this.invoiceRepository.create(invoiceData);
await this.invoiceRepository.save(invoice);
// Log the creation operation
await this.createLog(tenantId, invoice.id, null, CfdiOperationType.CREATE, null, CfdiStatus.DRAFT, true, userId);
logger.info('CFDI invoice created', {
cfdiId: invoice.id,
tenantId,
issuerRfc: invoice.issuerRfc,
receiverRfc: invoice.receiverRfc,
createdBy: userId,
});
return invoice;
} catch (error) {
logger.error('Error creating CFDI invoice', {
error: (error as Error).message,
tenantId,
});
throw error;
}
}
/**
* Update a CFDI invoice (only allowed in draft status)
*/
async update(
tenantId: string,
id: string,
dto: UpdateCfdiInvoiceDto,
userId: string
): Promise<CfdiInvoice> {
try {
const existing = await this.findById(tenantId, id);
if (existing.status !== CfdiStatus.DRAFT) {
throw new ValidationError(
`No se puede modificar un CFDI con estado '${existing.status}'. Solo se permiten cambios en estado 'draft'.`
);
}
// Update allowed fields
if (dto.invoiceId !== undefined) existing.invoiceId = dto.invoiceId;
if (dto.certificateId !== undefined) existing.certificateId = dto.certificateId;
if (dto.serie !== undefined) existing.serie = dto.serie;
if (dto.folio !== undefined) existing.folio = dto.folio;
if (dto.voucherType !== undefined) existing.voucherType = dto.voucherType as any;
if (dto.issuerRfc !== undefined) existing.issuerRfc = dto.issuerRfc;
if (dto.issuerName !== undefined) existing.issuerName = dto.issuerName;
if (dto.issuerFiscalRegime !== undefined) existing.issuerFiscalRegime = dto.issuerFiscalRegime;
if (dto.receiverRfc !== undefined) existing.receiverRfc = dto.receiverRfc;
if (dto.receiverName !== undefined) existing.receiverName = dto.receiverName;
if (dto.receiverFiscalRegime !== undefined) existing.receiverFiscalRegime = dto.receiverFiscalRegime;
if (dto.receiverZipCode !== undefined) existing.receiverZipCode = dto.receiverZipCode;
if (dto.receiverTaxResidence !== undefined) existing.receiverTaxResidence = dto.receiverTaxResidence;
if (dto.receiverTaxId !== undefined) existing.receiverTaxId = dto.receiverTaxId;
if (dto.cfdiUse !== undefined) existing.cfdiUse = dto.cfdiUse;
if (dto.subtotal !== undefined) existing.subtotal = dto.subtotal;
if (dto.discount !== undefined) existing.discount = dto.discount;
if (dto.total !== undefined) existing.total = dto.total;
if (dto.totalTransferredTaxes !== undefined) existing.totalTransferredTaxes = dto.totalTransferredTaxes;
if (dto.totalWithheldTaxes !== undefined) existing.totalWithheldTaxes = dto.totalWithheldTaxes;
if (dto.currency !== undefined) existing.currency = dto.currency;
if (dto.exchangeRate !== undefined) existing.exchangeRate = dto.exchangeRate;
if (dto.exportation !== undefined) existing.exportation = dto.exportation;
if (dto.paymentForm !== undefined) existing.paymentForm = dto.paymentForm;
if (dto.paymentMethod !== undefined) existing.paymentMethod = dto.paymentMethod;
if (dto.paymentConditions !== undefined) existing.paymentConditions = dto.paymentConditions;
if (dto.expeditionPlace !== undefined) existing.expeditionPlace = dto.expeditionPlace;
if (dto.confirmationCode !== undefined) existing.confirmationCode = dto.confirmationCode;
if (dto.relatedCfdiType !== undefined) existing.relatedCfdiType = dto.relatedCfdiType;
if (dto.globalInfoPeriodicity !== undefined) existing.globalInfoPeriodicity = dto.globalInfoPeriodicity;
if (dto.globalInfoMonths !== undefined) existing.globalInfoMonths = dto.globalInfoMonths;
if (dto.globalInfoYear !== undefined) existing.globalInfoYear = dto.globalInfoYear;
existing.updatedBy = userId;
await this.invoiceRepository.save(existing);
logger.info('CFDI invoice updated', {
cfdiId: id,
tenantId,
updatedBy: userId,
});
return await this.findById(tenantId, id);
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) throw error;
logger.error('Error updating CFDI invoice', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Request stamping for a CFDI invoice.
* Changes status from 'draft' to 'pending' and adds it to the stamp queue.
*/
async requestStamp(tenantId: string, id: string, userId: string): Promise<CfdiInvoice> {
try {
const invoice = await this.findById(tenantId, id);
if (invoice.status !== CfdiStatus.DRAFT && invoice.status !== CfdiStatus.ERROR) {
throw new ValidationError(
`No se puede solicitar timbrado para un CFDI con estado '${invoice.status}'. Solo se permite desde 'draft' o 'error'.`
);
}
// Validate required fields before allowing stamp request
this.validateInvoiceForStamping(invoice);
const previousStatus = invoice.status;
// Update status to pending
invoice.status = CfdiStatus.PENDING;
invoice.lastError = null;
invoice.errorDetails = null;
invoice.updatedBy = userId;
await this.invoiceRepository.save(invoice);
// Add to stamp queue
const queueEntry = this.stampQueueRepository.create({
tenantId,
documentType: 'invoice',
documentId: invoice.id,
priority: 5,
queueStatus: 'pending',
attempts: 0,
maxAttempts: 3,
createdBy: userId,
});
await this.stampQueueRepository.save(queueEntry);
// Log the stamp request
await this.createLog(
tenantId,
invoice.id,
invoice.uuid,
CfdiOperationType.STAMP,
previousStatus,
CfdiStatus.PENDING,
true,
userId
);
logger.info('CFDI stamp requested', {
cfdiId: id,
tenantId,
queueId: queueEntry.id,
requestedBy: userId,
});
return invoice;
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) throw error;
logger.error('Error requesting CFDI stamp', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Request cancellation for a stamped CFDI invoice.
* Creates a cancellation request record and updates invoice status.
*/
async requestCancellation(
tenantId: string,
id: string,
dto: CancellationRequestDto,
userId: string
): Promise<CfdiCancellation> {
try {
const invoice = await this.findById(tenantId, id);
if (invoice.status !== CfdiStatus.STAMPED) {
throw new ValidationError(
`No se puede cancelar un CFDI con estado '${invoice.status}'. Solo se permite cancelar CFDIs timbrados.`
);
}
if (!invoice.uuid) {
throw new ValidationError('El CFDI no tiene UUID asignado. No se puede solicitar cancelacion.');
}
// Check for reason '01' requiring a substitute UUID
if (dto.cancellationReason === CancellationReason.ERROR_WITH_RELATION && !dto.substituteUuid) {
throw new ValidationError(
'El motivo de cancelacion "01" (errores con relacion) requiere un UUID de CFDI sustituto.'
);
}
// Check if there's already an active cancellation request
const existingRequest = await this.cancellationRepository.findOne({
where: {
cfdiInvoiceId: id,
tenantId,
status: CancellationRequestStatus.PENDING,
},
});
if (existingRequest) {
throw new ConflictError('Ya existe una solicitud de cancelacion pendiente para este CFDI.');
}
// Create cancellation request
const cancellation = this.cancellationRepository.create({
tenantId,
cfdiInvoiceId: id,
cfdiUuid: invoice.uuid,
cancellationReason: dto.cancellationReason,
substituteUuid: dto.substituteUuid || null,
substituteCfdiId: dto.substituteCfdiId || null,
status: CancellationRequestStatus.PENDING,
reasonNotes: dto.reasonNotes || null,
internalNotes: dto.internalNotes || null,
requestedAt: new Date(),
retryCount: 0,
createdBy: userId,
});
await this.cancellationRepository.save(cancellation);
// Update invoice status
const previousStatus = invoice.status;
invoice.status = CfdiStatus.CANCELLATION_PENDING;
invoice.cancellationStatus = 'pending';
invoice.updatedBy = userId;
await this.invoiceRepository.save(invoice);
// Log the cancellation request
await this.createLog(
tenantId,
invoice.id,
invoice.uuid,
CfdiOperationType.CANCEL_REQUEST,
previousStatus,
CfdiStatus.CANCELLATION_PENDING,
true,
userId
);
logger.info('CFDI cancellation requested', {
cfdiId: id,
cfdiUuid: invoice.uuid,
cancellationId: cancellation.id,
reason: dto.cancellationReason,
tenantId,
requestedBy: userId,
});
return cancellation;
} catch (error) {
if (
error instanceof NotFoundError ||
error instanceof ValidationError ||
error instanceof ConflictError
) {
throw error;
}
logger.error('Error requesting CFDI cancellation', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Get CFDI status including SAT validation info
*/
async getStatus(tenantId: string, id: string): Promise<CfdiStatusResponse> {
try {
const invoice = await this.findById(tenantId, id);
return {
id: invoice.id,
status: invoice.status,
uuid: invoice.uuid,
stampDate: invoice.stampDate,
cancellationStatus: invoice.cancellationStatus,
cancellationDate: invoice.cancellationDate,
lastSatValidation: invoice.lastSatValidation,
satValidationStatus: invoice.satValidationStatus,
satValidationResponse: invoice.satValidationResponse,
lastError: invoice.lastError,
};
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Error getting CFDI status', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
// ===== Certificate Management =====
/**
* List certificates for a tenant with filters and pagination
*/
async listCertificates(
tenantId: string,
filters: CertificateFilters = {}
): Promise<{ data: CfdiCertificate[]; total: number }> {
try {
const { rfc, status, isDefault, page = 1, limit = 20 } = filters;
const skip = (page - 1) * limit;
const queryBuilder = this.certificateRepository
.createQueryBuilder('cert')
.where('cert.tenantId = :tenantId', { tenantId })
.andWhere('cert.deletedAt IS NULL');
if (rfc) {
queryBuilder.andWhere('cert.rfc = :rfc', { rfc });
}
if (status !== undefined) {
queryBuilder.andWhere('cert.status = :status', { status });
}
if (isDefault !== undefined) {
queryBuilder.andWhere('cert.isDefault = :isDefault', { isDefault });
}
const total = await queryBuilder.getCount();
const data = await queryBuilder
.orderBy('cert.createdAt', 'DESC')
.skip(skip)
.take(limit)
.getMany();
// Remove sensitive data before returning
const sanitized = data.map((cert) => {
const { privateKeyPemEncrypted, ...safe } = cert;
return safe as CfdiCertificate;
});
logger.debug('Certificates retrieved', { tenantId, count: sanitized.length, total });
return { data: sanitized, total };
} catch (error) {
logger.error('Error retrieving certificates', {
error: (error as Error).message,
tenantId,
});
throw error;
}
}
/**
* Upload/register a new certificate
*/
async uploadCertificate(
tenantId: string,
dto: UploadCertificateDto,
userId: string
): Promise<CfdiCertificate> {
try {
// Check for duplicate serial number
const existingBySerial = await this.certificateRepository.findOne({
where: { serialNumber: dto.serialNumber },
});
if (existingBySerial) {
throw new ConflictError(
`Ya existe un certificado con el numero de serie '${dto.serialNumber}'.`
);
}
// If this certificate is marked as default, unset other defaults for same tenant+rfc
if (dto.isDefault) {
await this.certificateRepository
.createQueryBuilder()
.update(CfdiCertificate)
.set({ isDefault: false, updatedBy: userId })
.where('tenantId = :tenantId', { tenantId })
.andWhere('rfc = :rfc', { rfc: dto.rfc })
.andWhere('isDefault = true')
.execute();
}
const certificate = this.certificateRepository.create({
tenantId,
rfc: dto.rfc,
certificateNumber: dto.certificateNumber,
serialNumber: dto.serialNumber,
certificatePem: dto.certificatePem,
privateKeyPemEncrypted: dto.privateKeyPemEncrypted,
issuedAt: new Date(dto.issuedAt),
expiresAt: new Date(dto.expiresAt),
status: CertificateStatus.ACTIVE,
issuerName: dto.issuerName || null,
subjectName: dto.subjectName || null,
description: dto.description || null,
notes: dto.notes || null,
isDefault: dto.isDefault || false,
createdBy: userId,
});
await this.certificateRepository.save(certificate);
logger.info('Certificate uploaded', {
certificateId: certificate.id,
tenantId,
rfc: dto.rfc,
serialNumber: dto.serialNumber,
createdBy: userId,
});
// Remove sensitive data before returning
const { privateKeyPemEncrypted, ...safeCertificate } = certificate;
return safeCertificate as CfdiCertificate;
} catch (error) {
if (error instanceof ConflictError) throw error;
logger.error('Error uploading certificate', {
error: (error as Error).message,
tenantId,
});
throw error;
}
}
// ===== Private Helpers =====
/**
* Validate that an invoice has all required fields for stamping
*/
private validateInvoiceForStamping(invoice: CfdiInvoice): void {
const missingFields: string[] = [];
if (!invoice.issuerRfc) missingFields.push('issuerRfc');
if (!invoice.issuerName) missingFields.push('issuerName');
if (!invoice.issuerFiscalRegime) missingFields.push('issuerFiscalRegime');
if (!invoice.receiverRfc) missingFields.push('receiverRfc');
if (!invoice.receiverName) missingFields.push('receiverName');
if (!invoice.cfdiUse) missingFields.push('cfdiUse');
if (!invoice.paymentForm) missingFields.push('paymentForm');
if (!invoice.paymentMethod) missingFields.push('paymentMethod');
if (!invoice.expeditionPlace) missingFields.push('expeditionPlace');
if (invoice.subtotal === undefined || invoice.subtotal === null) missingFields.push('subtotal');
if (invoice.total === undefined || invoice.total === null) missingFields.push('total');
if (missingFields.length > 0) {
throw new ValidationError(
`El CFDI no tiene todos los campos requeridos para timbrado. Campos faltantes: ${missingFields.join(', ')}`
);
}
}
/**
* Create an operation log entry
*/
private async createLog(
tenantId: string,
cfdiInvoiceId: string,
cfdiUuid: string | null,
operationType: CfdiOperationType,
statusBefore: string | null,
statusAfter: string,
success: boolean,
userId: string,
errorCode?: string,
errorMessage?: string
): Promise<CfdiLog> {
const log = this.logRepository.create({
tenantId,
cfdiInvoiceId,
cfdiUuid: cfdiUuid || null,
operationType,
statusBefore: statusBefore || null,
statusAfter,
success,
errorCode: errorCode || null,
errorMessage: errorMessage || null,
createdBy: userId,
});
await this.logRepository.save(log);
return log;
}
}
// ===== Export Singleton Instance =====
export const cfdiService = new CfdiService();

View File

@ -0,0 +1,10 @@
export {
cfdiService,
CreateCfdiInvoiceDto,
UpdateCfdiInvoiceDto,
CfdiFilters,
CertificateFilters,
UploadCertificateDto,
CancellationRequestDto,
CfdiStatusResponse,
} from './cfdi.service.js';

View File

@ -30,6 +30,6 @@ export class Country {
currencyCode: string | null; currencyCode: string | null;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
} }

View File

@ -38,6 +38,6 @@ export class Currency {
active: boolean; active: boolean;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
} }

View File

@ -121,10 +121,10 @@ export class DiscountRule {
}) })
conditionValue: number | null; conditionValue: number | null;
@Column({ type: 'timestamp', nullable: true, name: 'start_date' }) @Column({ type: 'timestamptz', nullable: true, name: 'start_date' })
startDate: Date | null; startDate: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'end_date' }) @Column({ type: 'timestamptz', nullable: true, name: 'end_date' })
endDate: Date | null; endDate: Date | null;
@Column({ type: 'integer', nullable: false, default: 10 }) @Column({ type: 'integer', nullable: false, default: 10 })
@ -143,13 +143,13 @@ export class DiscountRule {
isActive: boolean; isActive: boolean;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null; createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null; updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })

View File

@ -124,13 +124,13 @@ export class PaymentTerm {
lines: PaymentTermLine[]; lines: PaymentTermLine[];
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null; createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null; updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })

View File

@ -55,7 +55,7 @@ export class ProductCategory {
children: ProductCategory[]; children: ProductCategory[];
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -63,7 +63,7 @@ export class ProductCategory {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;
@ -71,7 +71,7 @@ export class ProductCategory {
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null; deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) @Column({ type: 'uuid', nullable: true, name: 'deleted_by' })

View File

@ -55,7 +55,7 @@ export class Sequence {
resetPeriod: ResetPeriod | null; resetPeriod: ResetPeriod | null;
@Column({ @Column({
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
name: 'last_reset_date', name: 'last_reset_date',
}) })
@ -65,7 +65,7 @@ export class Sequence {
isActive: boolean; isActive: boolean;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -73,7 +73,7 @@ export class Sequence {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;

View File

@ -69,7 +69,7 @@ export class Account {
children: Account[]; children: Account[];
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -77,7 +77,7 @@ export class Account {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;
@ -85,7 +85,7 @@ export class Account {
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null; deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) @Column({ type: 'uuid', nullable: true, name: 'deleted_by' })

View File

@ -44,7 +44,7 @@ export class FiscalPeriod {
}) })
status: FiscalPeriodStatus; status: FiscalPeriodStatus;
@Column({ type: 'timestamp', nullable: true, name: 'closed_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'closed_at' })
closedAt: Date | null; closedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'closed_by' }) @Column({ type: 'uuid', nullable: true, name: 'closed_by' })
@ -56,7 +56,7 @@ export class FiscalPeriod {
fiscalYear: FiscalYear; fiscalYear: FiscalYear;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })

View File

@ -59,7 +59,7 @@ export class FiscalYear {
periods: FiscalPeriod[]; periods: FiscalPeriod[];
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })

View File

@ -14,6 +14,7 @@ export { InvoiceLine } from './invoice-line.entity.js';
// Payment entities // Payment entities
export { Payment, PaymentType, PaymentMethod, PaymentStatus } from './payment.entity.js'; export { Payment, PaymentType, PaymentMethod, PaymentStatus } from './payment.entity.js';
export { PaymentInvoiceAllocation } from './payment-invoice-allocation.entity.js';
// Tax entities // Tax entities
export { Tax, TaxType } from './tax.entity.js'; export { Tax, TaxType } from './tax.entity.js';

View File

@ -67,12 +67,12 @@ export class InvoiceLine {
account: Account | null; account: Account | null;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;

View File

@ -122,7 +122,7 @@ export class Invoice {
lines: InvoiceLine[]; lines: InvoiceLine[];
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -130,7 +130,7 @@ export class Invoice {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;
@ -138,13 +138,13 @@ export class Invoice {
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'validated_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'validated_at' })
validatedAt: Date | null; validatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'validated_by' }) @Column({ type: 'uuid', nullable: true, name: 'validated_by' })
validatedBy: string | null; validatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'cancelled_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'cancelled_at' })
cancelledAt: Date | null; cancelledAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'cancelled_by' }) @Column({ type: 'uuid', nullable: true, name: 'cancelled_by' })

View File

@ -54,6 +54,6 @@ export class JournalEntryLine {
account: Account; account: Account;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
} }

View File

@ -74,7 +74,7 @@ export class JournalEntry {
lines: JournalEntryLine[]; lines: JournalEntryLine[];
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -82,7 +82,7 @@ export class JournalEntry {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;
@ -90,13 +90,13 @@ export class JournalEntry {
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'posted_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'posted_at' })
postedAt: Date | null; postedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'posted_by' }) @Column({ type: 'uuid', nullable: true, name: 'posted_by' })
postedBy: string | null; postedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'cancelled_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'cancelled_at' })
cancelledAt: Date | null; cancelledAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'cancelled_by' }) @Column({ type: 'uuid', nullable: true, name: 'cancelled_by' })

View File

@ -70,7 +70,7 @@ export class Journal {
defaultAccount: Account | null; defaultAccount: Account | null;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -78,7 +78,7 @@ export class Journal {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;
@ -86,7 +86,7 @@ export class Journal {
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null; deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) @Column({ type: 'uuid', nullable: true, name: 'deleted_by' })

View File

@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { Payment } from './payment.entity.js';
import { Invoice } from './invoice.entity.js';
@Entity({ schema: 'financial', name: 'payment_invoice_allocations' })
@Unique('uq_payment_invoice_allocations_payment_invoice', ['paymentId', 'invoiceId'])
export class PaymentInvoiceAllocation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'payment_id' })
paymentId: string;
@Column({ type: 'uuid', nullable: false, name: 'invoice_id' })
invoiceId: string;
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: false })
amount: number;
@Column({ type: 'date', nullable: false, default: () => 'CURRENT_DATE', name: 'allocation_date' })
allocationDate: Date;
// Relations
@ManyToOne(() => Payment, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'payment_id' })
payment: Payment;
@ManyToOne(() => Invoice, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'invoice_id' })
invoice: Invoice;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
}

View File

@ -111,7 +111,7 @@ export class Payment {
journalEntry: JournalEntry | null; journalEntry: JournalEntry | null;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -119,7 +119,7 @@ export class Payment {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;
@ -127,7 +127,7 @@ export class Payment {
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'posted_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'posted_at' })
postedAt: Date | null; postedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'posted_by' }) @Column({ type: 'uuid', nullable: true, name: 'posted_by' })

View File

@ -60,7 +60,7 @@ export class Tax {
company: Company; company: Company;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -68,7 +68,7 @@ export class Tax {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;

View File

@ -75,6 +75,6 @@ export class InventoryAdjustmentLine {
lot: Lot | null; lot: Lot | null;
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
} }

View File

@ -68,7 +68,7 @@ export class InventoryAdjustment {
lines: InventoryAdjustmentLine[]; lines: InventoryAdjustmentLine[];
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -76,7 +76,7 @@ export class InventoryAdjustment {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;

View File

@ -78,7 +78,7 @@ export class Location {
stockQuants: StockQuant[]; stockQuants: StockQuant[];
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -86,7 +86,7 @@ export class Location {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;

View File

@ -56,7 +56,7 @@ export class Lot {
stockQuants: StockQuant[]; stockQuants: StockQuant[];
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })

View File

@ -64,10 +64,10 @@ export class Picking {
@Column({ type: 'uuid', nullable: true, name: 'partner_id' }) @Column({ type: 'uuid', nullable: true, name: 'partner_id' })
partnerId: string | null; partnerId: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'scheduled_date' }) @Column({ type: 'timestamptz', nullable: true, name: 'scheduled_date' })
scheduledDate: Date | null; scheduledDate: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'date_done' }) @Column({ type: 'timestamptz', nullable: true, name: 'date_done' })
dateDone: Date | null; dateDone: Date | null;
@Column({ type: 'varchar', length: 255, nullable: true }) @Column({ type: 'varchar', length: 255, nullable: true })
@ -84,7 +84,7 @@ export class Picking {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
notes: string | null; notes: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'validated_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'validated_at' })
validatedAt: Date | null; validatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'validated_by' }) @Column({ type: 'uuid', nullable: true, name: 'validated_by' })
@ -107,7 +107,7 @@ export class Picking {
moves: StockMove[]; moves: StockMove[];
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -115,7 +115,7 @@ export class Picking {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;

View File

@ -147,7 +147,7 @@ export class Product {
lots: Lot[]; lots: Lot[];
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -155,7 +155,7 @@ export class Product {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;
@ -163,7 +163,7 @@ export class Product {
@Column({ type: 'uuid', nullable: true, name: 'updated_by' }) @Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null; updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null; deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) @Column({ type: 'uuid', nullable: true, name: 'deleted_by' })

View File

@ -58,7 +58,7 @@ export class StockMove {
}) })
status: MoveStatus; status: MoveStatus;
@Column({ type: 'timestamp', nullable: true }) @Column({ type: 'timestamptz', nullable: true })
date: Date | null; date: Date | null;
@Column({ type: 'varchar', length: 255, nullable: true }) @Column({ type: 'varchar', length: 255, nullable: true })
@ -86,7 +86,7 @@ export class StockMove {
lot: Lot | null; lot: Lot | null;
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -94,7 +94,7 @@ export class StockMove {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;

View File

@ -54,12 +54,12 @@ export class StockQuant {
lot: Lot | null; lot: Lot | null;
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;

View File

@ -67,7 +67,7 @@ export class StockValuationLayer {
company: Company; company: Company;
// Auditoría // Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -75,7 +75,7 @@ export class StockValuationLayer {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;

View File

@ -5,13 +5,13 @@ Este modulo esta deprecated desde 2026-02-03.
## Razon ## Razon
La funcionalidad de facturacion esta siendo consolidada en el modulo `financial`. La funcionalidad de facturacion esta siendo consolidada en el modulo `financial`.
El modulo `invoices` (schema `billing`) fue disenado para facturacion operativa, El modulo `invoices` (schema `operations`, formerly `billing`) fue disenado para facturacion operativa,
pero para mantener una arquitectura limpia, toda la logica de facturas debe pero para mantener una arquitectura limpia, toda la logica de facturas debe
residir en un unico modulo. residir en un unico modulo.
## Mapeo de Entidades ## Mapeo de Entidades
| Invoices (billing) | Financial (financial) | Notas | | Invoices (operations) | Financial (financial) | Notas |
|------------------------|------------------------------|------------------------------------------| |------------------------|------------------------------|------------------------------------------|
| Invoice | financial/invoice.entity.ts | financial tiene integracion con journals | | Invoice | financial/invoice.entity.ts | financial tiene integracion con journals |
| InvoiceItem | financial/invoice-line.entity.ts | Renombrado a InvoiceLine | | InvoiceItem | financial/invoice-line.entity.ts | Renombrado a InvoiceLine |
@ -21,8 +21,8 @@ residir en un unico modulo.
## Diferencias Clave ## Diferencias Clave
### Invoices (deprecated) ### Invoices (deprecated)
- Schema: `billing` - Schema: `operations` (moved from `billing` to resolve conflict with SaaS billing schema)
- Enfoque: Facturacion operativa, CFDI Mexico, SaaS billing - Enfoque: Facturacion operativa, CFDI Mexico
- Sin integracion contable directa - Sin integracion contable directa
### Financial (activo) ### Financial (activo)

View File

@ -7,7 +7,7 @@ import { Invoice } from './invoice.entity';
* @deprecated Since 2026-02-03. Use financial/invoice-line.entity.ts instead. * @deprecated Since 2026-02-03. Use financial/invoice-line.entity.ts instead.
* @see InvoiceLine from '@modules/financial/entities' * @see InvoiceLine from '@modules/financial/entities'
*/ */
@Entity({ name: 'invoice_items', schema: 'billing' }) @Entity({ name: 'invoice_items', schema: 'operations' })
export class InvoiceItem { export class InvoiceItem {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;

View File

@ -14,7 +14,7 @@ import { InvoiceItem } from './invoice-item.entity';
* Unified Invoice Entity * Unified Invoice Entity
* *
* Combines fields from commercial invoices and SaaS billing invoices. * Combines fields from commercial invoices and SaaS billing invoices.
* Schema: billing * Schema: operations
* *
* Context discriminator: * Context discriminator:
* - 'commercial': Sales/purchase invoices (salesOrderId, purchaseOrderId, partnerId) * - 'commercial': Sales/purchase invoices (salesOrderId, purchaseOrderId, partnerId)
@ -28,7 +28,7 @@ export type InvoiceType = 'sale' | 'purchase' | 'credit_note' | 'debit_note';
export type InvoiceStatus = 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'overdue' | 'void' | 'refunded' | 'cancelled' | 'voided'; export type InvoiceStatus = 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'overdue' | 'void' | 'refunded' | 'cancelled' | 'voided';
export type InvoiceContext = 'commercial' | 'saas'; export type InvoiceContext = 'commercial' | 'saas';
@Entity({ name: 'invoices', schema: 'billing' }) @Entity({ name: 'invoices', schema: 'operations' })
export class Invoice { export class Invoice {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;

View File

@ -10,7 +10,7 @@ import { Invoice } from './invoice.entity';
* @deprecated Since 2026-02-03. Use payment_invoices relationship in financial module. * @deprecated Since 2026-02-03. Use payment_invoices relationship in financial module.
* @see Payment from '@modules/financial/entities' * @see Payment from '@modules/financial/entities'
*/ */
@Entity({ name: 'payment_allocations', schema: 'billing' }) @Entity({ name: 'payment_allocations', schema: 'operations' })
export class PaymentAllocation { export class PaymentAllocation {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;

View File

@ -6,7 +6,7 @@ import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateCol
* @deprecated Since 2026-02-03. Use financial/payment.entity.ts instead. * @deprecated Since 2026-02-03. Use financial/payment.entity.ts instead.
* @see Payment from '@modules/financial/entities' * @see Payment from '@modules/financial/entities'
*/ */
@Entity({ name: 'payments', schema: 'billing' }) @Entity({ name: 'payments', schema: 'operations' })
export class Payment { export class Payment {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;

View File

@ -0,0 +1,392 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import {
mobileService,
CreateSessionDto,
RegisterPushTokenDto,
AddToQueueDto,
SessionFilters,
QueueFilters,
} from './services/index.js';
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
// ===== Zod Validation Schemas =====
const createSessionSchema = z.object({
user_id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
device_id: z.string().uuid().optional(),
deviceId: z.string().uuid().optional(),
branch_id: z.string().uuid().optional(),
branchId: z.string().uuid().optional(),
active_profile_id: z.string().uuid().optional(),
activeProfileId: z.string().uuid().optional(),
active_profile_code: z.string().max(10).optional(),
activeProfileCode: z.string().max(10).optional(),
app_version: z.string().max(20).optional(),
appVersion: z.string().max(20).optional(),
platform: z.enum(['ios', 'android']).optional(),
os_version: z.string().max(20).optional(),
osVersion: z.string().max(20).optional(),
expires_at: z.string().datetime().optional(),
expiresAt: z.string().datetime().optional(),
});
const sessionQuerySchema = z.object({
user_id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
device_id: z.string().uuid().optional(),
deviceId: z.string().uuid().optional(),
status: z.enum(['active', 'paused', 'expired', 'terminated']).optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
const registerPushTokenSchema = z.object({
user_id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
device_id: z.string().uuid().optional(),
deviceId: z.string().uuid().optional(),
token: z.string().min(1, 'El token es requerido'),
platform: z.enum(['ios', 'android']),
provider: z.enum(['firebase', 'apns', 'fcm']).default('firebase'),
subscribed_topics: z.array(z.string()).optional(),
subscribedTopics: z.array(z.string()).optional(),
});
const addToQueueSchema = z.object({
user_id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
device_id: z.string().uuid().optional(),
deviceId: z.string().uuid().optional(),
session_id: z.string().uuid().optional(),
sessionId: z.string().uuid().optional(),
entity_type: z.string().min(1).max(50).optional(),
entityType: z.string().min(1).max(50).optional(),
entity_id: z.string().uuid().optional(),
entityId: z.string().uuid().optional(),
operation: z.enum(['create', 'update', 'delete']),
payload: z.record(z.any()),
metadata: z.record(z.any()).optional(),
sequence_number: z.coerce.number().int().optional(),
sequenceNumber: z.coerce.number().int().optional(),
depends_on: z.string().uuid().optional(),
dependsOn: z.string().uuid().optional(),
max_retries: z.coerce.number().int().positive().optional(),
maxRetries: z.coerce.number().int().positive().optional(),
});
const queueQuerySchema = z.object({
user_id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
device_id: z.string().uuid().optional(),
deviceId: z.string().uuid().optional(),
status: z.enum(['pending', 'processing', 'completed', 'failed', 'conflict']).optional(),
entity_type: z.string().optional(),
entityType: z.string().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
// ===== MobileController Class =====
class MobileController {
// ===== Session Endpoints =====
/**
* POST /api/mobile/sessions - Create a new mobile session
*/
async createSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = createSessionSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de sesion invalidos', parseResult.error.errors);
}
const data = parseResult.data;
const tenantId = req.user!.tenantId;
const currentUserId = req.user!.userId;
const dto: CreateSessionDto = {
userId: data.userId || data.user_id || currentUserId,
deviceId: (data.deviceId || data.device_id)!,
branchId: data.branchId || data.branch_id,
activeProfileId: data.activeProfileId || data.active_profile_id,
activeProfileCode: data.activeProfileCode || data.active_profile_code,
appVersion: data.appVersion || data.app_version,
platform: data.platform,
osVersion: data.osVersion || data.os_version,
expiresAt: (data.expiresAt || data.expires_at)
? new Date((data.expiresAt || data.expires_at)!)
: undefined,
};
if (!dto.deviceId) {
throw new ValidationError('El deviceId es requerido');
}
const session = await mobileService.createSession(tenantId, dto);
const response: ApiResponse = {
success: true,
data: session,
message: 'Sesion movil creada exitosamente',
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
/**
* GET /api/mobile/sessions - List active sessions
*/
async listSessions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const queryResult = sessionQuerySchema.safeParse(req.query);
if (!queryResult.success) {
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
}
const data = queryResult.data;
const tenantId = req.user!.tenantId;
const filters: SessionFilters = {
userId: data.userId || data.user_id,
deviceId: data.deviceId || data.device_id,
status: data.status,
page: data.page,
limit: data.limit,
};
const result = await mobileService.getActiveSessions(tenantId, filters);
const response: ApiResponse = {
success: true,
data: result.data,
meta: {
total: result.total,
page: filters.page || 1,
limit: filters.limit || 20,
totalPages: Math.ceil(result.total / (filters.limit || 20)),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* DELETE /api/mobile/sessions/:id - Revoke a session
*/
async revokeSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const tenantId = req.user!.tenantId;
const session = await mobileService.revokeSession(tenantId, id);
const response: ApiResponse = {
success: true,
data: session,
message: 'Sesion movil revocada exitosamente',
};
res.json(response);
} catch (error) {
next(error);
}
}
// ===== Push Token Endpoints =====
/**
* POST /api/mobile/push-tokens - Register a push token
*/
async registerPushToken(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = registerPushTokenSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de token invalidos', parseResult.error.errors);
}
const data = parseResult.data;
const tenantId = req.user!.tenantId;
const currentUserId = req.user!.userId;
const dto: RegisterPushTokenDto = {
userId: data.userId || data.user_id || currentUserId,
deviceId: (data.deviceId || data.device_id)!,
token: data.token,
platform: data.platform,
provider: data.provider,
subscribedTopics: data.subscribedTopics || data.subscribed_topics,
};
if (!dto.deviceId) {
throw new ValidationError('El deviceId es requerido');
}
const pushToken = await mobileService.registerPushToken(tenantId, dto);
const response: ApiResponse = {
success: true,
data: pushToken,
message: 'Token de push registrado exitosamente',
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
/**
* DELETE /api/mobile/push-tokens/:id - Remove a push token
*/
async removePushToken(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const tenantId = req.user!.tenantId;
await mobileService.removePushToken(tenantId, id);
const response: ApiResponse = {
success: true,
message: 'Token de push removido exitosamente',
};
res.json(response);
} catch (error) {
next(error);
}
}
// ===== Sync Queue Endpoints =====
/**
* GET /api/mobile/sync-queue - Get sync queue items
*/
async getQueueItems(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const queryResult = queueQuerySchema.safeParse(req.query);
if (!queryResult.success) {
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
}
const data = queryResult.data;
const tenantId = req.user!.tenantId;
const filters: QueueFilters = {
userId: data.userId || data.user_id,
deviceId: data.deviceId || data.device_id,
status: data.status,
entityType: data.entityType || data.entity_type,
page: data.page,
limit: data.limit,
};
const result = await mobileService.getQueueItems(tenantId, filters);
const response: ApiResponse = {
success: true,
data: result.data,
meta: {
total: result.total,
page: filters.page || 1,
limit: filters.limit || 20,
totalPages: Math.ceil(result.total / (filters.limit || 20)),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* POST /api/mobile/sync-queue - Add item to sync queue
*/
async addToQueue(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = addToQueueSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de cola de sincronizacion invalidos', parseResult.error.errors);
}
const data = parseResult.data;
const tenantId = req.user!.tenantId;
const currentUserId = req.user!.userId;
const entityType = data.entityType || data.entity_type;
const sequenceNumber = data.sequenceNumber ?? data.sequence_number;
if (!entityType) {
throw new ValidationError('El entityType es requerido');
}
if (sequenceNumber === undefined || sequenceNumber === null) {
throw new ValidationError('El sequenceNumber es requerido');
}
const dto: AddToQueueDto = {
userId: data.userId || data.user_id || currentUserId,
deviceId: (data.deviceId || data.device_id)!,
sessionId: data.sessionId || data.session_id,
entityType,
entityId: data.entityId || data.entity_id,
operation: data.operation,
payload: data.payload,
metadata: data.metadata,
sequenceNumber,
dependsOn: data.dependsOn || data.depends_on,
maxRetries: data.maxRetries ?? data.max_retries,
};
if (!dto.deviceId) {
throw new ValidationError('El deviceId es requerido');
}
const queueItem = await mobileService.addToQueue(tenantId, dto);
const response: ApiResponse = {
success: true,
data: queueItem,
message: 'Elemento agregado a la cola de sincronizacion',
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
/**
* PUT /api/mobile/sync-queue/:id/process - Process a sync queue item
*/
async processQueueItem(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const tenantId = req.user!.tenantId;
const queueItem = await mobileService.processQueueItem(tenantId, id);
const response: ApiResponse = {
success: true,
data: queueItem,
message: 'Elemento de sincronizacion procesado exitosamente',
};
res.json(response);
} catch (error) {
next(error);
}
}
}
export const mobileController = new MobileController();

View File

@ -0,0 +1,63 @@
import { Router } from 'express';
import { mobileController } from './mobile.controller.js';
import { authenticate } from '../../shared/middleware/auth.middleware.js';
import { requireAccess } from '../../shared/middleware/rbac.middleware.js';
const router = Router();
// All routes require authentication
router.use(authenticate);
// ============================================================================
// SESSION ROUTES
// ============================================================================
// Create a new mobile session
router.post('/sessions', requireAccess({ roles: ['admin', 'manager', 'sales', 'cashier'], permission: 'mobile:create' }), (req, res, next) =>
mobileController.createSession(req, res, next)
);
// List active sessions
router.get('/sessions', requireAccess({ roles: ['admin', 'manager'], permission: 'mobile:read' }), (req, res, next) =>
mobileController.listSessions(req, res, next)
);
// Revoke a mobile session
router.delete('/sessions/:id', requireAccess({ roles: ['admin', 'manager'], permission: 'mobile:delete' }), (req, res, next) =>
mobileController.revokeSession(req, res, next)
);
// ============================================================================
// PUSH TOKEN ROUTES
// ============================================================================
// Register a push token
router.post('/push-tokens', requireAccess({ roles: ['admin', 'manager', 'sales', 'cashier'], permission: 'mobile:create' }), (req, res, next) =>
mobileController.registerPushToken(req, res, next)
);
// Remove a push token
router.delete('/push-tokens/:id', requireAccess({ roles: ['admin', 'manager', 'sales', 'cashier'], permission: 'mobile:delete' }), (req, res, next) =>
mobileController.removePushToken(req, res, next)
);
// ============================================================================
// SYNC QUEUE ROUTES
// ============================================================================
// Get sync queue items
router.get('/sync-queue', requireAccess({ roles: ['admin', 'manager', 'sales', 'cashier'], permission: 'mobile:read' }), (req, res, next) =>
mobileController.getQueueItems(req, res, next)
);
// Add item to sync queue
router.post('/sync-queue', requireAccess({ roles: ['admin', 'manager', 'sales', 'cashier'], permission: 'mobile:create' }), (req, res, next) =>
mobileController.addToQueue(req, res, next)
);
// Process a sync queue item
router.put('/sync-queue/:id/process', requireAccess({ roles: ['admin', 'manager'], permission: 'mobile:update' }), (req, res, next) =>
mobileController.processQueueItem(req, res, next)
);
export default router;

View File

@ -0,0 +1,9 @@
export {
mobileService,
CreateSessionDto,
SessionFilters,
RegisterPushTokenDto,
UpdatePushTokenDto,
AddToQueueDto,
QueueFilters,
} from './mobile.service.js';

View File

@ -0,0 +1,661 @@
import { Repository, IsNull, In } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm.js';
import {
MobileSession,
MobileSessionStatus,
OfflineSyncQueue,
SyncStatus,
SyncOperation,
PushToken,
PushProvider,
} from '../entities/index.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ===== DTOs =====
export interface CreateSessionDto {
userId: string;
deviceId: string;
branchId?: string;
activeProfileId?: string;
activeProfileCode?: string;
appVersion?: string;
platform?: string;
osVersion?: string;
expiresAt?: Date;
}
export interface SessionFilters {
userId?: string;
deviceId?: string;
status?: MobileSessionStatus;
page?: number;
limit?: number;
}
export interface RegisterPushTokenDto {
userId: string;
deviceId: string;
token: string;
platform: string;
provider?: PushProvider;
subscribedTopics?: string[];
}
export interface UpdatePushTokenDto {
token?: string;
isActive?: boolean;
subscribedTopics?: string[];
}
export interface AddToQueueDto {
userId: string;
deviceId: string;
sessionId?: string;
entityType: string;
entityId?: string;
operation: SyncOperation;
payload: Record<string, any>;
metadata?: Record<string, any>;
sequenceNumber: number;
dependsOn?: string;
maxRetries?: number;
}
export interface QueueFilters {
userId?: string;
deviceId?: string;
status?: SyncStatus;
entityType?: string;
page?: number;
limit?: number;
}
// ===== MobileService Class =====
class MobileService {
private sessionRepository: Repository<MobileSession>;
private pushTokenRepository: Repository<PushToken>;
private syncQueueRepository: Repository<OfflineSyncQueue>;
constructor() {
this.sessionRepository = AppDataSource.getRepository(MobileSession);
this.pushTokenRepository = AppDataSource.getRepository(PushToken);
this.syncQueueRepository = AppDataSource.getRepository(OfflineSyncQueue);
}
// ===== Session Management =====
/**
* Create a new mobile session
*/
async createSession(
tenantId: string,
dto: CreateSessionDto
): Promise<MobileSession> {
try {
const sessionData: Partial<MobileSession> = {
tenantId,
userId: dto.userId,
deviceId: dto.deviceId,
status: 'active',
isOfflineMode: false,
pendingSyncCount: 0,
startedAt: new Date(),
lastActivityAt: new Date(),
};
if (dto.branchId) sessionData.branchId = dto.branchId;
if (dto.activeProfileId) sessionData.activeProfileId = dto.activeProfileId;
if (dto.activeProfileCode) sessionData.activeProfileCode = dto.activeProfileCode;
if (dto.appVersion) sessionData.appVersion = dto.appVersion;
if (dto.platform) sessionData.platform = dto.platform;
if (dto.osVersion) sessionData.osVersion = dto.osVersion;
if (dto.expiresAt) sessionData.expiresAt = dto.expiresAt;
const session = this.sessionRepository.create(sessionData);
await this.sessionRepository.save(session);
logger.info('Mobile session created', {
sessionId: session.id,
tenantId,
userId: dto.userId,
deviceId: dto.deviceId,
});
return session;
} catch (error) {
logger.error('Error creating mobile session', {
error: (error as Error).message,
tenantId,
userId: dto.userId,
});
throw error;
}
}
/**
* Get active sessions for a tenant with optional filters
*/
async getActiveSessions(
tenantId: string,
filters: SessionFilters = {}
): Promise<{ data: MobileSession[]; total: number }> {
try {
const { userId, deviceId, status, page = 1, limit = 20 } = filters;
const skip = (page - 1) * limit;
const queryBuilder = this.sessionRepository
.createQueryBuilder('session')
.where('session.tenantId = :tenantId', { tenantId });
if (userId) {
queryBuilder.andWhere('session.userId = :userId', { userId });
}
if (deviceId) {
queryBuilder.andWhere('session.deviceId = :deviceId', { deviceId });
}
if (status) {
queryBuilder.andWhere('session.status = :status', { status });
} else {
queryBuilder.andWhere('session.status = :status', { status: 'active' });
}
const total = await queryBuilder.getCount();
const data = await queryBuilder
.orderBy('session.lastActivityAt', 'DESC')
.skip(skip)
.take(limit)
.getMany();
logger.debug('Active sessions retrieved', { tenantId, count: data.length, total });
return { data, total };
} catch (error) {
logger.error('Error retrieving active sessions', {
error: (error as Error).message,
tenantId,
});
throw error;
}
}
/**
* Revoke (terminate) a mobile session
*/
async revokeSession(
tenantId: string,
sessionId: string
): Promise<MobileSession> {
try {
const session = await this.sessionRepository.findOne({
where: { id: sessionId, tenantId },
});
if (!session) {
throw new NotFoundError('Sesion movil no encontrada');
}
if (session.status === 'terminated') {
throw new ValidationError('La sesion ya fue terminada');
}
session.status = 'terminated';
session.endedAt = new Date();
await this.sessionRepository.save(session);
logger.info('Mobile session revoked', {
sessionId,
tenantId,
userId: session.userId,
});
return session;
} catch (error) {
logger.error('Error revoking mobile session', {
error: (error as Error).message,
tenantId,
sessionId,
});
throw error;
}
}
/**
* Find sessions by device ID
*/
async findByDeviceId(
tenantId: string,
deviceId: string
): Promise<MobileSession[]> {
try {
const sessions = await this.sessionRepository.find({
where: { tenantId, deviceId },
order: { lastActivityAt: 'DESC' },
});
logger.debug('Sessions found by device', { tenantId, deviceId, count: sessions.length });
return sessions;
} catch (error) {
logger.error('Error finding sessions by device', {
error: (error as Error).message,
tenantId,
deviceId,
});
throw error;
}
}
/**
* Find sessions by user ID
*/
async findByUserId(
tenantId: string,
userId: string
): Promise<MobileSession[]> {
try {
const sessions = await this.sessionRepository.find({
where: { tenantId, userId },
order: { lastActivityAt: 'DESC' },
});
logger.debug('Sessions found by user', { tenantId, userId, count: sessions.length });
return sessions;
} catch (error) {
logger.error('Error finding sessions by user', {
error: (error as Error).message,
tenantId,
userId,
});
throw error;
}
}
// ===== Push Token Management =====
/**
* Register a new push token
*/
async registerPushToken(
tenantId: string,
dto: RegisterPushTokenDto
): Promise<PushToken> {
try {
// Check if a token already exists for this device+platform combination
const existing = await this.pushTokenRepository.findOne({
where: {
tenantId,
deviceId: dto.deviceId,
platform: dto.platform,
},
});
if (existing) {
// Update the existing token instead of creating a duplicate
existing.token = dto.token;
existing.userId = dto.userId;
existing.isActive = true;
existing.isValid = true;
existing.invalidReason = null as any;
existing.lastUsedAt = new Date();
if (dto.provider) existing.provider = dto.provider;
if (dto.subscribedTopics) existing.subscribedTopics = dto.subscribedTopics;
await this.pushTokenRepository.save(existing);
logger.info('Push token updated (existing device)', {
tokenId: existing.id,
tenantId,
deviceId: dto.deviceId,
});
return existing;
}
const tokenData: Partial<PushToken> = {
tenantId,
userId: dto.userId,
deviceId: dto.deviceId,
token: dto.token,
platform: dto.platform,
provider: dto.provider || 'firebase',
isActive: true,
isValid: true,
subscribedTopics: dto.subscribedTopics || [],
lastUsedAt: new Date(),
};
const pushToken = this.pushTokenRepository.create(tokenData);
await this.pushTokenRepository.save(pushToken);
logger.info('Push token registered', {
tokenId: pushToken.id,
tenantId,
userId: dto.userId,
deviceId: dto.deviceId,
platform: dto.platform,
});
return pushToken;
} catch (error) {
logger.error('Error registering push token', {
error: (error as Error).message,
tenantId,
deviceId: dto.deviceId,
});
throw error;
}
}
/**
* Update an existing push token
*/
async updatePushToken(
tenantId: string,
tokenId: string,
dto: UpdatePushTokenDto
): Promise<PushToken> {
try {
const pushToken = await this.pushTokenRepository.findOne({
where: { id: tokenId, tenantId },
});
if (!pushToken) {
throw new NotFoundError('Token de push no encontrado');
}
if (dto.token !== undefined) pushToken.token = dto.token;
if (dto.isActive !== undefined) pushToken.isActive = dto.isActive;
if (dto.subscribedTopics !== undefined) pushToken.subscribedTopics = dto.subscribedTopics;
pushToken.lastUsedAt = new Date();
await this.pushTokenRepository.save(pushToken);
logger.info('Push token updated', {
tokenId,
tenantId,
});
return pushToken;
} catch (error) {
logger.error('Error updating push token', {
error: (error as Error).message,
tenantId,
tokenId,
});
throw error;
}
}
/**
* Remove (deactivate) a push token
*/
async removePushToken(
tenantId: string,
tokenId: string
): Promise<void> {
try {
const pushToken = await this.pushTokenRepository.findOne({
where: { id: tokenId, tenantId },
});
if (!pushToken) {
throw new NotFoundError('Token de push no encontrado');
}
pushToken.isActive = false;
pushToken.isValid = false;
pushToken.invalidReason = 'Removed by user';
await this.pushTokenRepository.save(pushToken);
logger.info('Push token removed', {
tokenId,
tenantId,
userId: pushToken.userId,
});
} catch (error) {
logger.error('Error removing push token', {
error: (error as Error).message,
tenantId,
tokenId,
});
throw error;
}
}
// ===== Offline Sync Queue =====
/**
* Get sync queue items with optional filters
*/
async getQueueItems(
tenantId: string,
filters: QueueFilters = {}
): Promise<{ data: OfflineSyncQueue[]; total: number }> {
try {
const { userId, deviceId, status, entityType, page = 1, limit = 20 } = filters;
const skip = (page - 1) * limit;
const queryBuilder = this.syncQueueRepository
.createQueryBuilder('queue')
.where('queue.tenantId = :tenantId', { tenantId });
if (userId) {
queryBuilder.andWhere('queue.userId = :userId', { userId });
}
if (deviceId) {
queryBuilder.andWhere('queue.deviceId = :deviceId', { deviceId });
}
if (status) {
queryBuilder.andWhere('queue.status = :status', { status });
}
if (entityType) {
queryBuilder.andWhere('queue.entityType = :entityType', { entityType });
}
const total = await queryBuilder.getCount();
const data = await queryBuilder
.orderBy('queue.sequenceNumber', 'ASC')
.skip(skip)
.take(limit)
.getMany();
logger.debug('Sync queue items retrieved', { tenantId, count: data.length, total });
return { data, total };
} catch (error) {
logger.error('Error retrieving sync queue items', {
error: (error as Error).message,
tenantId,
});
throw error;
}
}
/**
* Add an item to the offline sync queue
*/
async addToQueue(
tenantId: string,
dto: AddToQueueDto
): Promise<OfflineSyncQueue> {
try {
const queueData: Partial<OfflineSyncQueue> = {
tenantId,
userId: dto.userId,
deviceId: dto.deviceId,
entityType: dto.entityType,
operation: dto.operation,
payload: dto.payload,
metadata: dto.metadata || {},
sequenceNumber: dto.sequenceNumber,
status: 'pending',
retryCount: 0,
maxRetries: dto.maxRetries ?? 3,
};
if (dto.sessionId) queueData.sessionId = dto.sessionId;
if (dto.entityId) queueData.entityId = dto.entityId;
if (dto.dependsOn) queueData.dependsOn = dto.dependsOn;
const queueItem = this.syncQueueRepository.create(queueData);
await this.syncQueueRepository.save(queueItem);
logger.info('Item added to sync queue', {
queueItemId: queueItem.id,
tenantId,
entityType: dto.entityType,
operation: dto.operation,
sequenceNumber: dto.sequenceNumber,
});
return queueItem;
} catch (error) {
logger.error('Error adding item to sync queue', {
error: (error as Error).message,
tenantId,
entityType: dto.entityType,
});
throw error;
}
}
/**
* Process a sync queue item (mark as processing, then completed or failed)
*/
async processQueueItem(
tenantId: string,
queueItemId: string
): Promise<OfflineSyncQueue> {
try {
const queueItem = await this.syncQueueRepository.findOne({
where: { id: queueItemId, tenantId },
});
if (!queueItem) {
throw new NotFoundError('Elemento de cola de sincronizacion no encontrado');
}
if (queueItem.status === 'completed') {
throw new ValidationError('El elemento ya fue procesado');
}
if (queueItem.status === 'processing') {
throw new ValidationError('El elemento ya esta en procesamiento');
}
// Check dependency
if (queueItem.dependsOn) {
const dependency = await this.syncQueueRepository.findOne({
where: { id: queueItem.dependsOn, tenantId },
});
if (dependency && dependency.status !== 'completed') {
throw new ValidationError(
`El elemento depende de ${queueItem.dependsOn} que aun no ha sido completado`
);
}
}
queueItem.status = 'processing';
await this.syncQueueRepository.save(queueItem);
// Mark as completed
queueItem.status = 'completed';
queueItem.processedAt = new Date();
await this.syncQueueRepository.save(queueItem);
logger.info('Sync queue item processed', {
queueItemId,
tenantId,
entityType: queueItem.entityType,
operation: queueItem.operation,
});
return queueItem;
} catch (error) {
// If the item was set to processing but failed, mark it as failed
if (error instanceof NotFoundError || error instanceof ValidationError) {
throw error;
}
try {
const failedItem = await this.syncQueueRepository.findOne({
where: { id: queueItemId, tenantId },
});
if (failedItem && failedItem.status === 'processing') {
failedItem.status = 'failed';
failedItem.retryCount += 1;
failedItem.lastError = (error as Error).message;
await this.syncQueueRepository.save(failedItem);
}
} catch (saveError) {
logger.error('Error saving failed queue item status', {
error: (saveError as Error).message,
queueItemId,
});
}
logger.error('Error processing sync queue item', {
error: (error as Error).message,
tenantId,
queueItemId,
});
throw error;
}
}
/**
* Clear completed/failed queue items for a device
*/
async clearQueue(
tenantId: string,
deviceId: string,
statuses: SyncStatus[] = ['completed', 'failed']
): Promise<number> {
try {
const result = await this.syncQueueRepository.delete({
tenantId,
deviceId,
status: In(statuses),
});
const deletedCount = result.affected || 0;
logger.info('Sync queue cleared', {
tenantId,
deviceId,
deletedCount,
statuses,
});
return deletedCount;
} catch (error) {
logger.error('Error clearing sync queue', {
error: (error as Error).message,
tenantId,
deviceId,
});
throw error;
}
}
}
// ===== Export Singleton Instance =====
export const mobileService = new MobileService();

View File

@ -209,36 +209,21 @@ export class CreatePartnerAddressDto {
partnerId: string; partnerId: string;
@IsOptional() @IsOptional()
@IsEnum(['billing', 'shipping', 'both']) @IsEnum(['billing', 'shipping', 'main', 'other'])
addressType?: 'billing' | 'shipping' | 'both'; addressType?: 'billing' | 'shipping' | 'main' | 'other';
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isDefault?: boolean; isDefault?: boolean;
@IsOptional()
@IsString()
@MaxLength(100)
label?: string;
@IsString() @IsString()
@MaxLength(200) @MaxLength(200)
street: string; addressLine1: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(200)
exteriorNumber?: string; addressLine2?: string;
@IsOptional()
@IsString()
@MaxLength(20)
interiorNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)
neighborhood?: string;
@IsString() @IsString()
@MaxLength(100) @MaxLength(100)
@ -247,21 +232,32 @@ export class CreatePartnerAddressDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(100) @MaxLength(100)
municipality?: string; state?: string;
@IsOptional()
@IsString() @IsString()
@MaxLength(100) @MaxLength(20)
state: string; postalCode?: string;
@IsString()
@MaxLength(10)
postalCode: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(3) @MaxLength(3)
country?: string; country?: string;
@IsOptional()
@IsString()
@MaxLength(100)
contactName?: string;
@IsOptional()
@IsString()
@MaxLength(30)
contactPhone?: string;
@IsOptional()
@IsEmail()
contactEmail?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
reference?: string; reference?: string;
@ -273,6 +269,10 @@ export class CreatePartnerAddressDto {
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
longitude?: number; longitude?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
} }
export class CreatePartnerContactDto { export class CreatePartnerContactDto {
@ -281,12 +281,12 @@ export class CreatePartnerContactDto {
@IsString() @IsString()
@MaxLength(200) @MaxLength(200)
fullName: string; name: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(100) @MaxLength(100)
position?: string; jobTitle?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@ -308,9 +308,8 @@ export class CreatePartnerContactDto {
mobile?: string; mobile?: string;
@IsOptional() @IsOptional()
@IsString() @IsEnum(['general', 'billing', 'purchasing', 'sales', 'technical'])
@MaxLength(30) contactType?: 'general' | 'billing' | 'purchasing' | 'sales' | 'technical';
extension?: string;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@ -318,15 +317,7 @@ export class CreatePartnerContactDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isBillingContact?: boolean; isActive?: boolean;
@IsOptional()
@IsBoolean()
isShippingContact?: boolean;
@IsOptional()
@IsBoolean()
receivesNotifications?: boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@ -4,18 +4,27 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
DeleteDateColumn,
Index, Index,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Partner } from './partner.entity'; import { Partner } from './partner.entity';
/**
* PartnerAddress Entity
*
* Synchronized with DDL: database/ddl/16-partners.sql
* Table: partners.partner_addresses
*
* Represents addresses associated with a partner (billing, shipping, etc.).
*/
@Entity({ name: 'partner_addresses', schema: 'partners' }) @Entity({ name: 'partner_addresses', schema: 'partners' })
export class PartnerAddress { export class PartnerAddress {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Index() @Index('idx_partner_addresses_partner')
@Column({ name: 'partner_id', type: 'uuid' }) @Column({ name: 'partner_id', type: 'uuid' })
partnerId: string; partnerId: string;
@ -24,54 +33,56 @@ export class PartnerAddress {
partner: Partner; partner: Partner;
// Tipo de direccion // Tipo de direccion
@Index() @Index('idx_partner_addresses_type')
@Column({ name: 'address_type', type: 'varchar', length: 20, default: 'billing' }) @Column({ name: 'address_type', type: 'varchar', length: 20, default: 'billing' })
addressType: 'billing' | 'shipping' | 'both'; addressType: 'billing' | 'shipping' | 'main' | 'other';
@Column({ name: 'is_default', type: 'boolean', default: false })
isDefault: boolean;
// Direccion // Direccion
@Column({ type: 'varchar', length: 100, nullable: true }) @Column({ name: 'address_line1', type: 'varchar', length: 200 })
label: string; addressLine1: string;
@Column({ type: 'varchar', length: 200 }) @Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true })
street: string; addressLine2: string | null;
@Column({ name: 'exterior_number', type: 'varchar', length: 20, nullable: true })
exteriorNumber: string;
@Column({ name: 'interior_number', type: 'varchar', length: 20, nullable: true })
interiorNumber: string;
@Column({ type: 'varchar', length: 100, nullable: true })
neighborhood: string;
@Column({ type: 'varchar', length: 100 }) @Column({ type: 'varchar', length: 100 })
city: string; city: string;
@Column({ type: 'varchar', length: 100, nullable: true }) @Column({ type: 'varchar', length: 100, nullable: true })
municipality: string; state: string | null;
@Column({ type: 'varchar', length: 100 }) @Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true })
state: string; postalCode: string | null;
@Column({ name: 'postal_code', type: 'varchar', length: 10 })
postalCode: string;
@Column({ type: 'varchar', length: 3, default: 'MEX' }) @Column({ type: 'varchar', length: 3, default: 'MEX' })
country: string; country: string;
// Contacto en esta direccion
@Column({ name: 'contact_name', type: 'varchar', length: 100, nullable: true })
contactName: string | null;
@Column({ name: 'contact_phone', type: 'varchar', length: 30, nullable: true })
contactPhone: string | null;
@Column({ name: 'contact_email', type: 'varchar', length: 255, nullable: true })
contactEmail: string | null;
// Referencia // Referencia
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
reference: string; reference: string | null;
// Geolocalizacion // Geolocalizacion
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
latitude: number; latitude: number | null;
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
longitude: number; longitude: number | null;
// Estado
@Column({ name: 'is_default', type: 'boolean', default: false })
isDefault: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
// Metadata // Metadata
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
@ -79,4 +90,7 @@ export class PartnerAddress {
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date; updatedAt: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
} }

View File

@ -4,18 +4,27 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
DeleteDateColumn,
Index, Index,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Partner } from './partner.entity'; import { Partner } from './partner.entity';
/**
* PartnerContact Entity
*
* Synchronized with DDL: database/ddl/16-partners.sql
* Table: partners.partner_contacts
*
* Represents individual contact persons of a partner.
*/
@Entity({ name: 'partner_contacts', schema: 'partners' }) @Entity({ name: 'partner_contacts', schema: 'partners' })
export class PartnerContact { export class PartnerContact {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Index() @Index('idx_partner_contacts_partner')
@Column({ name: 'partner_id', type: 'uuid' }) @Column({ name: 'partner_id', type: 'uuid' })
partnerId: string; partnerId: string;
@ -23,45 +32,42 @@ export class PartnerContact {
@JoinColumn({ name: 'partner_id' }) @JoinColumn({ name: 'partner_id' })
partner: Partner; partner: Partner;
// Datos del contacto // Datos personales
@Column({ name: 'full_name', type: 'varchar', length: 200 }) @Column({ type: 'varchar', length: 200 })
fullName: string; name: string;
@Column({ name: 'job_title', type: 'varchar', length: 100, nullable: true })
jobTitle: string | null;
@Column({ type: 'varchar', length: 100, nullable: true }) @Column({ type: 'varchar', length: 100, nullable: true })
position: string; department: string | null;
@Column({ type: 'varchar', length: 100, nullable: true })
department: string;
// Contacto // Contacto
@Index('idx_partner_contacts_email')
@Column({ type: 'varchar', length: 255, nullable: true }) @Column({ type: 'varchar', length: 255, nullable: true })
email: string; email: string | null;
@Column({ type: 'varchar', length: 30, nullable: true }) @Column({ type: 'varchar', length: 30, nullable: true })
phone: string; phone: string | null;
@Column({ type: 'varchar', length: 30, nullable: true }) @Column({ type: 'varchar', length: 30, nullable: true })
mobile: string; mobile: string | null;
@Column({ type: 'varchar', length: 30, nullable: true }) // Rol
extension: string; @Index('idx_partner_contacts_type')
@Column({ name: 'contact_type', type: 'varchar', length: 20, default: 'general' })
contactType: 'general' | 'billing' | 'purchasing' | 'sales' | 'technical';
// Flags
@Column({ name: 'is_primary', type: 'boolean', default: false }) @Column({ name: 'is_primary', type: 'boolean', default: false })
isPrimary: boolean; isPrimary: boolean;
@Column({ name: 'is_billing_contact', type: 'boolean', default: false }) // Estado
isBillingContact: boolean; @Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_shipping_contact', type: 'boolean', default: false })
isShippingContact: boolean;
@Column({ name: 'receives_notifications', type: 'boolean', default: true })
receivesNotifications: boolean;
// Notas // Notas
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
notes: string; notes: string | null;
// Metadata // Metadata
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
@ -69,4 +75,7 @@ export class PartnerContact {
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date; updatedAt: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
} }

View File

@ -1 +1,6 @@
export * from './project.entity.js';
export * from './project-stage.entity.js';
export * from './task.entity.js';
export * from './milestone.entity.js';
export * from './project-member.entity.js';
export * from './timesheet.entity.js'; export * from './timesheet.entity.js';

View File

@ -0,0 +1,64 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum MilestoneStatus {
PENDING = 'pending',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'projects', name: 'milestones' })
@Index('idx_milestones_tenant', ['tenantId'])
@Index('idx_milestones_project', ['projectId'])
@Index('idx_milestones_status', ['status'])
@Index('idx_milestones_deadline', ['dateDeadline'])
export class MilestoneEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: false, name: 'project_id' })
projectId: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'date', nullable: true, name: 'date_deadline' })
dateDeadline: Date | null;
@Column({
type: 'enum',
enum: MilestoneStatus,
default: MilestoneStatus.PENDING,
nullable: false,
})
status: MilestoneStatus;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamptz',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
}

View File

@ -0,0 +1,34 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
Unique,
} from 'typeorm';
@Entity({ schema: 'projects', name: 'project_members' })
@Index('idx_project_members_tenant', ['tenantId'])
@Index('idx_project_members_project', ['projectId'])
@Index('idx_project_members_user', ['userId'])
@Unique(['projectId', 'userId'])
export class ProjectMemberEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: false, name: 'project_id' })
projectId: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({ type: 'varchar', length: 50, default: 'member', nullable: false })
role: string;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity({ schema: 'projects', name: 'project_stages' })
@Index('idx_project_stages_tenant', ['tenantId'])
@Index('idx_project_stages_project', ['projectId'])
@Index('idx_project_stages_sequence', ['sequence'])
export class ProjectStageEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'project_id' })
projectId: string | null;
@Column({ type: 'varchar', length: 100, nullable: false })
name: string;
@Column({ type: 'int', default: 0, nullable: false })
sequence: number;
@Column({ type: 'boolean', default: false, nullable: false })
fold: boolean;
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_closed' })
isClosed: boolean;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamptz',
nullable: true,
})
updatedAt: Date | null;
}

View File

@ -0,0 +1,111 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';
export enum ProjectStatus {
DRAFT = 'draft',
ACTIVE = 'active',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
ON_HOLD = 'on_hold',
}
export enum ProjectPrivacy {
PUBLIC = 'public',
PRIVATE = 'private',
FOLLOWERS = 'followers',
}
@Entity({ schema: 'projects', name: 'projects' })
@Index('idx_projects_tenant', ['tenantId'])
@Index('idx_projects_company', ['companyId'])
@Index('idx_projects_manager', ['managerId'])
@Index('idx_projects_partner', ['partnerId'])
@Index('idx_projects_status', ['status'])
@Index('idx_projects_code', ['code'])
@Unique(['companyId', 'code'])
export class ProjectEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
companyId: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'varchar', length: 50, nullable: true })
code: string | null;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'uuid', nullable: true, name: 'manager_id' })
managerId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
partnerId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'analytic_account_id' })
analyticAccountId: string | null;
@Column({ type: 'date', nullable: true, name: 'date_start' })
dateStart: Date | null;
@Column({ type: 'date', nullable: true, name: 'date_end' })
dateEnd: Date | null;
@Column({
type: 'enum',
enum: ProjectStatus,
default: ProjectStatus.DRAFT,
nullable: false,
})
status: ProjectStatus;
@Column({
type: 'enum',
enum: ProjectPrivacy,
default: ProjectPrivacy.PUBLIC,
nullable: false,
})
privacy: ProjectPrivacy;
@Column({ type: 'boolean', default: true, nullable: false, name: 'allow_timesheets' })
allowTimesheets: boolean;
@Column({ type: 'varchar', length: 20, nullable: true })
color: string | null;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamptz',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -0,0 +1,110 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum TaskPriority {
LOW = 'low',
NORMAL = 'normal',
HIGH = 'high',
URGENT = 'urgent',
}
export enum TaskStatus {
TODO = 'todo',
IN_PROGRESS = 'in_progress',
REVIEW = 'review',
DONE = 'done',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'projects', name: 'tasks' })
@Index('idx_tasks_tenant', ['tenantId'])
@Index('idx_tasks_project', ['projectId'])
@Index('idx_tasks_stage', ['stageId'])
@Index('idx_tasks_parent', ['parentId'])
@Index('idx_tasks_assigned', ['assignedTo'])
@Index('idx_tasks_status', ['status'])
@Index('idx_tasks_priority', ['priority'])
@Index('idx_tasks_deadline', ['dateDeadline'])
@Index('idx_tasks_sequence', ['projectId', 'sequence'])
export class TaskEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: false, name: 'project_id' })
projectId: string;
@Column({ type: 'uuid', nullable: true, name: 'stage_id' })
stageId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'parent_id' })
parentId: string | null;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'uuid', nullable: true, name: 'assigned_to' })
assignedTo: string | null;
@Column({ type: 'date', nullable: true, name: 'date_deadline' })
dateDeadline: Date | null;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0, nullable: false, name: 'estimated_hours' })
estimatedHours: number;
@Column({
type: 'enum',
enum: TaskPriority,
default: TaskPriority.NORMAL,
nullable: false,
})
priority: TaskPriority;
@Column({
type: 'enum',
enum: TaskStatus,
default: TaskStatus.TODO,
nullable: false,
})
status: TaskStatus;
@Column({ type: 'int', default: 0, nullable: false })
sequence: number;
@Column({ type: 'varchar', length: 20, nullable: true })
color: string | null;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamptz',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -73,11 +73,11 @@ export class TimesheetEntity {
@Column({ type: 'uuid', nullable: true, name: 'approved_by' }) @Column({ type: 'uuid', nullable: true, name: 'approved_by' })
approvedBy: string | null; approvedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'approved_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'approved_at' })
approvedAt: Date | null; approvedAt: Date | null;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' }) @Column({ type: 'uuid', nullable: true, name: 'created_by' })
@ -85,7 +85,7 @@ export class TimesheetEntity {
@UpdateDateColumn({ @UpdateDateColumn({
name: 'updated_at', name: 'updated_at',
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
updatedAt: Date | null; updatedAt: Date | null;

View File

@ -26,8 +26,8 @@ export class PermissionsController {
const params: PaginationParams = { page, limit, sortBy, sortOrder }; const params: PaginationParams = { page, limit, sortBy, sortOrder };
// Build filter // Build filter
const filter: { module?: string; resource?: string; action?: PermissionAction } = {}; const filter: { category?: string; resource?: string; action?: PermissionAction } = {};
if (req.query.module) filter.module = req.query.module as string; if (req.query.category) filter.category = req.query.category as string;
if (req.query.resource) filter.resource = req.query.resource as string; if (req.query.resource) filter.resource = req.query.resource as string;
if (req.query.action) filter.action = req.query.action as PermissionAction; if (req.query.action) filter.action = req.query.action as PermissionAction;
@ -53,9 +53,9 @@ export class PermissionsController {
/** /**
* GET /permissions/modules - Get list of all modules * GET /permissions/modules - Get list of all modules
*/ */
async getModules(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> { async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try { try {
const modules = await permissionsService.getModules(); const modules = await permissionsService.getCategories();
const response: ApiResponse = { const response: ApiResponse = {
success: true, success: true,
@ -91,7 +91,7 @@ export class PermissionsController {
*/ */
async getGrouped(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> { async getGrouped(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try { try {
const grouped = await permissionsService.getGroupedByModule(); const grouped = await permissionsService.getGroupedByCategory();
const response: ApiResponse = { const response: ApiResponse = {
success: true, success: true,
@ -107,10 +107,10 @@ export class PermissionsController {
/** /**
* GET /permissions/by-module/:module - Get all permissions for a module * GET /permissions/by-module/:module - Get all permissions for a module
*/ */
async getByModule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> { async getByCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try { try {
const module = req.params.module; const category = req.params.category;
const permissions = await permissionsService.getByModule(module); const permissions = await permissionsService.getByCategory(category);
const response: ApiResponse = { const response: ApiResponse = {
success: true, success: true,

View File

@ -23,9 +23,9 @@ router.get('/', requireAccess({ roles: ['admin', 'manager'], permission: 'permis
permissionsController.findAll(req, res, next) permissionsController.findAll(req, res, next)
); );
// Get available modules (admin, manager) // Get available categories (admin, manager)
router.get('/modules', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) => router.get('/categories', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) =>
permissionsController.getModules(req, res, next) permissionsController.getCategories(req, res, next)
); );
// Get available resources (admin, manager) // Get available resources (admin, manager)
@ -38,9 +38,9 @@ router.get('/grouped', requireAccess({ roles: ['admin', 'manager'], permission:
permissionsController.getGrouped(req, res, next) permissionsController.getGrouped(req, res, next)
); );
// Get permissions by module (admin, manager) // Get permissions by category (admin, manager)
router.get('/by-module/:module', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) => router.get('/by-category/:category', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) =>
permissionsController.getByModule(req, res, next) permissionsController.getByCategory(req, res, next)
); );
// Get permission matrix for admin UI (admin only) // Get permission matrix for admin UI (admin only)

View File

@ -7,7 +7,7 @@ import { logger } from '../../../shared/utils/logger.js';
// ===== Interfaces ===== // ===== Interfaces =====
export interface PermissionFilter { export interface PermissionFilter {
module?: string; category?: string;
resource?: string; resource?: string;
action?: PermissionAction; action?: PermissionAction;
} }
@ -15,7 +15,7 @@ export interface PermissionFilter {
export interface EffectivePermission { export interface EffectivePermission {
resource: string; resource: string;
action: string; action: string;
module: string | null; category: string | null;
fromRoles: string[]; fromRoles: string[];
} }
@ -50,8 +50,8 @@ class PermissionsService {
.take(limit); .take(limit);
// Apply filters // Apply filters
if (filter?.module) { if (filter?.category) {
queryBuilder.andWhere('permission.module = :module', { module: filter.module }); queryBuilder.andWhere('permission.category = :category', { category: filter.category });
} }
if (filter?.resource) { if (filter?.resource) {
queryBuilder.andWhere('permission.resource LIKE :resource', { queryBuilder.andWhere('permission.resource LIKE :resource', {
@ -96,23 +96,23 @@ class PermissionsService {
/** /**
* Get all unique modules * Get all unique modules
*/ */
async getModules(): Promise<string[]> { async getCategories(): Promise<string[]> {
const result = await this.permissionRepository const result = await this.permissionRepository
.createQueryBuilder('permission') .createQueryBuilder('permission')
.select('DISTINCT permission.module', 'module') .select('DISTINCT permission.category', 'category')
.where('permission.module IS NOT NULL') .where('permission.category IS NOT NULL')
.orderBy('permission.module', 'ASC') .orderBy('permission.category', 'ASC')
.getRawMany(); .getRawMany();
return result.map(r => r.module); return result.map(r => r.category);
} }
/** /**
* Get all permissions for a specific module * Get all permissions for a specific module
*/ */
async getByModule(module: string): Promise<Permission[]> { async getByCategory(category: string): Promise<Permission[]> {
return await this.permissionRepository.find({ return await this.permissionRepository.find({
where: { module }, where: { category },
order: { resource: 'ASC', action: 'ASC' }, order: { resource: 'ASC', action: 'ASC' },
}); });
} }
@ -133,19 +133,19 @@ class PermissionsService {
/** /**
* Get permissions grouped by module * Get permissions grouped by module
*/ */
async getGroupedByModule(): Promise<Record<string, Permission[]>> { async getGroupedByCategory(): Promise<Record<string, Permission[]>> {
const permissions = await this.permissionRepository.find({ const permissions = await this.permissionRepository.find({
order: { module: 'ASC', resource: 'ASC', action: 'ASC' }, order: { category: 'ASC', resource: 'ASC', action: 'ASC' },
}); });
const grouped: Record<string, Permission[]> = {}; const grouped: Record<string, Permission[]> = {};
for (const permission of permissions) { for (const permission of permissions) {
const module = permission.module || 'other'; const cat = permission.category || 'other';
if (!grouped[module]) { if (!grouped[cat]) {
grouped[module] = []; grouped[cat] = [];
} }
grouped[module].push(permission); grouped[cat].push(permission);
} }
return grouped; return grouped;
@ -188,7 +188,7 @@ class PermissionsService {
permissionMap.set(key, { permissionMap.set(key, {
resource: permission.resource, resource: permission.resource,
action: permission.action, action: permission.action,
module: permission.module, category: permission.category,
fromRoles: [role.name], fromRoles: [role.name],
}); });
} }
@ -243,7 +243,7 @@ class PermissionsService {
if (role.deletedAt) continue; if (role.deletedAt) continue;
// Super admin role has all permissions // Super admin role has all permissions
if (role.code === 'super_admin') { if (role.isSuperadmin) {
return true; return true;
} }
@ -299,7 +299,7 @@ class PermissionsService {
async getPermissionMatrix( async getPermissionMatrix(
tenantId: string tenantId: string
): Promise<{ ): Promise<{
roles: Array<{ id: string; name: string; code: string }>; roles: Array<{ id: string; name: string }>;
permissions: Permission[]; permissions: Permission[];
matrix: Record<string, string[]>; matrix: Record<string, string[]>;
}> { }> {
@ -313,7 +313,7 @@ class PermissionsService {
// Get all permissions // Get all permissions
const permissions = await this.permissionRepository.find({ const permissions = await this.permissionRepository.find({
order: { module: 'ASC', resource: 'ASC', action: 'ASC' }, order: { category: 'ASC', resource: 'ASC', action: 'ASC' },
}); });
// Build matrix: roleId -> [permissionIds] // Build matrix: roleId -> [permissionIds]
@ -323,7 +323,7 @@ class PermissionsService {
} }
return { return {
roles: roles.map(r => ({ id: r.id, name: r.name, code: r.code })), roles: roles.map(r => ({ id: r.id, name: r.name })),
permissions, permissions,
matrix, matrix,
}; };

View File

@ -8,7 +8,6 @@ import { logger } from '../../../shared/utils/logger.js';
export interface CreateRoleDto { export interface CreateRoleDto {
name: string; name: string;
code: string;
description?: string; description?: string;
color?: string; color?: string;
permissionIds?: string[]; permissionIds?: string[];
@ -99,23 +98,23 @@ class RolesService {
} }
/** /**
* Get a role by code * Get a role by name
*/ */
async findByCode(tenantId: string, code: string): Promise<Role | null> { async findByName(tenantId: string, name: string): Promise<Role | null> {
try { try {
return await this.roleRepository.findOne({ return await this.roleRepository.findOne({
where: { where: {
code, name,
tenantId, tenantId,
deletedAt: undefined, deletedAt: undefined,
}, },
relations: ['permissions'], relations: ['permissions'],
}); });
} catch (error) { } catch (error) {
logger.error('Error finding role by code', { logger.error('Error finding role by name', {
error: (error as Error).message, error: (error as Error).message,
tenantId, tenantId,
code, name,
}); });
throw error; throw error;
} }
@ -130,22 +129,16 @@ class RolesService {
createdBy: string createdBy: string
): Promise<Role> { ): Promise<Role> {
try { try {
// Validate code uniqueness within tenant // Validate name uniqueness within tenant
const existing = await this.findByCode(tenantId, data.code); const existing = await this.findByName(tenantId, data.name);
if (existing) { if (existing) {
throw new ValidationError('Ya existe un rol con este código'); throw new ValidationError('Ya existe un rol con este nombre');
}
// Validate code format
if (!/^[a-z_]+$/.test(data.code)) {
throw new ValidationError('El código debe contener solo letras minúsculas y guiones bajos');
} }
// Create role // Create role
const role = this.roleRepository.create({ const role = this.roleRepository.create({
tenantId, tenantId,
name: data.name, name: data.name,
code: data.code,
description: data.description || null, description: data.description || null,
color: data.color || null, color: data.color || null,
isSystem: false, isSystem: false,
@ -165,7 +158,7 @@ class RolesService {
logger.info('Role created', { logger.info('Role created', {
roleId: role.id, roleId: role.id,
tenantId, tenantId,
code: role.code, name: role.name,
createdBy, createdBy,
}); });
@ -251,7 +244,6 @@ class RolesService {
// Soft delete // Soft delete
role.deletedAt = new Date(); role.deletedAt = new Date();
role.deletedBy = deletedBy;
await this.roleRepository.save(role); await this.roleRepository.save(role);

View File

@ -82,13 +82,13 @@ export class StorageFile {
@Column({ name: 'thumbnail_url', type: 'text', nullable: true }) @Column({ name: 'thumbnail_url', type: 'text', nullable: true })
thumbnailUrl: string; thumbnailUrl: string;
@Column({ name: 'thumbnails', type: 'jsonb', default: {} }) @Column({ name: 'thumbnails', type: 'jsonb', default: '{}' })
thumbnails: Record<string, string>; thumbnails: Record<string, string>;
@Column({ name: 'metadata', type: 'jsonb', default: {} }) @Column({ name: 'metadata', type: 'jsonb', default: '{}' })
metadata: Record<string, any>; metadata: Record<string, any>;
@Column({ name: 'tags', type: 'text', array: true, default: [] }) @Column({ name: 'tags', type: 'text', array: true, default: '{}' })
tags: string[]; tags: string[];
@Column({ name: 'alt_text', type: 'text', nullable: true }) @Column({ name: 'alt_text', type: 'text', nullable: true })

View File

@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken';
import { config } from '../../config/index.js'; import { config } from '../../config/index.js';
import { AuthenticatedRequest, JwtPayload, UnauthorizedError, ForbiddenError } from '../types/index.js'; import { AuthenticatedRequest, JwtPayload, UnauthorizedError, ForbiddenError } from '../types/index.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { permissionsService } from '../../modules/roles/permissions.service.js'; import { permissionsService } from '../../modules/roles/services/permissions.service.js';
import { import {
checkCachedPermission, checkCachedPermission,
getCachedPermissions, getCachedPermissions,

View File

@ -1,7 +1,7 @@
import { Response, NextFunction } from 'express'; import { Response, NextFunction } from 'express';
import { AuthenticatedRequest, UnauthorizedError, ForbiddenError } from '../types/index.js'; import { AuthenticatedRequest, UnauthorizedError, ForbiddenError } from '../types/index.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { permissionsService } from '../../modules/roles/permissions.service.js'; import { permissionsService } from '../../modules/roles/services/permissions.service.js';
import { import {
checkCachedPermission, checkCachedPermission,
getCachedPermissions, getCachedPermissions,

View File

@ -1,6 +1,6 @@
import { redisClient, isRedisConnected } from '../../config/redis.js'; import { redisClient, isRedisConnected } from '../../config/redis.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { EffectivePermission } from '../../modules/roles/permissions.service.js'; import { EffectivePermission } from '../../modules/roles/services/permissions.service.js';
/** /**
* Service for caching user permissions in Redis * Service for caching user permissions in Redis