diff --git a/src/app.ts b/src/app.ts index 8a6fbc7..abe98a5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,8 +29,10 @@ import invoicesRoutes from './modules/invoices/invoices.routes.js'; import productsRoutes from './modules/products/products.routes.js'; import warehousesRoutes from './modules/warehouses/warehouses.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 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'; const app: Application = express(); @@ -86,8 +88,10 @@ app.use(`${apiPrefix}/invoices`, invoicesRoutes); app.use(`${apiPrefix}/products`, productsRoutes); app.use(`${apiPrefix}/warehouses`, warehousesRoutes); app.use(`${apiPrefix}/fiscal`, fiscalRoutes); +app.use(`${apiPrefix}/cfdi`, cfdiRoutes); app.use(`${apiPrefix}/audit`, auditRoutes); app.use(`${apiPrefix}/feature-flags`, featureFlagsRoutes); +app.use(`${apiPrefix}/mobile`, mobileRoutes); // 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) diff --git a/src/config/typeorm.ts b/src/config/typeorm.ts index f375916..c991e5a 100644 --- a/src/config/typeorm.ts +++ b/src/config/typeorm.ts @@ -23,6 +23,10 @@ import { OAuthProvider, OAuthUserLink, OAuthState, + MfaDevice, + MfaBackupCode, + LoginAttempt, + TokenBlacklist, } from '../modules/auth/entities/index.js'; // Import Core Module Entities @@ -86,6 +90,15 @@ import { WithholdingType, } 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 { SystemSetting, @@ -130,6 +143,10 @@ export const AppDataSource = new DataSource({ OAuthProvider, OAuthUserLink, OAuthState, + MfaDevice, + MfaBackupCode, + LoginAttempt, + TokenBlacklist, // Core Module Entities Partner, Currency, @@ -179,6 +196,14 @@ export const AppDataSource = new DataSource({ PaymentMethod, PaymentType, WithholdingType, + // CFDI Entities + CfdiInvoice, + CfdiCertificate, + CfdiCancellation, + CfdiLog, + CfdiPaymentComplement, + CfdiPacConfiguration, + CfdiStampQueue, // Settings Entities SystemSetting, PlanSetting, diff --git a/src/modules/auth/entities/company.entity.ts b/src/modules/auth/entities/company.entity.ts index b5bdd70..b6e714b 100644 --- a/src/modules/auth/entities/company.entity.ts +++ b/src/modules/auth/entities/company.entity.ts @@ -69,7 +69,7 @@ export class Company { users: User[]; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -77,7 +77,7 @@ export class Company { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; @@ -85,7 +85,7 @@ export class Company { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' }) deletedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) diff --git a/src/modules/auth/entities/device.entity.ts b/src/modules/auth/entities/device.entity.ts index 16eaeec..5253884 100644 --- a/src/modules/auth/entities/device.entity.ts +++ b/src/modules/auth/entities/device.entity.ts @@ -51,7 +51,7 @@ export class Device { @Column({ type: 'timestamptz', nullable: true, name: 'last_active_at' }) lastActiveAt: Date; - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) diff --git a/src/modules/auth/entities/group.entity.ts b/src/modules/auth/entities/group.entity.ts index c616efd..a959689 100644 --- a/src/modules/auth/entities/group.entity.ts +++ b/src/modules/auth/entities/group.entity.ts @@ -69,19 +69,19 @@ export class Group { deletedByUser: User | null; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) createdBy: string | null; - @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) updatedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' }) deletedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) diff --git a/src/modules/auth/entities/index.ts b/src/modules/auth/entities/index.ts index 27dcf9e..c42844b 100644 --- a/src/modules/auth/entities/index.ts +++ b/src/modules/auth/entities/index.ts @@ -20,6 +20,12 @@ export { ProfileModule } from './profile-module.entity.js'; export { UserProfileAssignment } from './user-profile-assignment.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: // - UserProfile, ProfileTool, ProfileModule, UserProfileAssignment, Person -> profiles/entities/ // - Device, BiometricCredential, DeviceSession, DeviceActivityLog -> biometrics/entities/ diff --git a/src/modules/auth/entities/login-attempt.entity.ts b/src/modules/auth/entities/login-attempt.entity.ts new file mode 100644 index 0000000..bfe369d --- /dev/null +++ b/src/modules/auth/entities/login-attempt.entity.ts @@ -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; +} diff --git a/src/modules/auth/entities/mfa-backup-code.entity.ts b/src/modules/auth/entities/mfa-backup-code.entity.ts new file mode 100644 index 0000000..f1add5b --- /dev/null +++ b/src/modules/auth/entities/mfa-backup-code.entity.ts @@ -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; +} diff --git a/src/modules/auth/entities/mfa-device.entity.ts b/src/modules/auth/entities/mfa-device.entity.ts new file mode 100644 index 0000000..c4a2697 --- /dev/null +++ b/src/modules/auth/entities/mfa-device.entity.ts @@ -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; +} diff --git a/src/modules/auth/entities/oauth-provider.entity.ts b/src/modules/auth/entities/oauth-provider.entity.ts index d019d86..3ae1fd3 100644 --- a/src/modules/auth/entities/oauth-provider.entity.ts +++ b/src/modules/auth/entities/oauth-provider.entity.ts @@ -2,190 +2,88 @@ import { Entity, PrimaryGeneratedColumn, Column, - CreateDateColumn, - UpdateDateColumn, Index, ManyToOne, JoinColumn, + Unique, } from 'typeorm'; -import { Tenant } from './tenant.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' }) -@Index('idx_oauth_providers_enabled', ['isEnabled']) -@Index('idx_oauth_providers_tenant', ['tenantId']) -@Index('idx_oauth_providers_code', ['code']) +@Unique(['provider', 'providerUserId']) +@Index('idx_oauth_providers_user', ['userId']) +@Index('idx_oauth_providers_provider', ['provider', 'providerUserId']) +@Index('idx_oauth_providers_email', ['providerEmail']) export class OAuthProvider { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'uuid', nullable: true, name: 'tenant_id' }) - tenantId: string | null; + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; - @Column({ type: 'varchar', length: 50, nullable: false, unique: true }) - code: string; + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; - @Column({ type: 'varchar', length: 100, nullable: false }) - name: 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; + // Provider info + @Column({ type: 'varchar', length: 50, nullable: false }) + provider: string; @Column({ type: 'varchar', - length: 500, + length: 255, nullable: false, - name: 'token_endpoint', + name: 'provider_user_id', }) - tokenEndpoint: string; + providerUserId: string; @Column({ type: 'varchar', - length: 500, - 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', + length: 255, 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; + + // Timestamps @Column({ - type: 'jsonb', - nullable: false, - name: 'claim_mapping', - default: { - sub: 'oauth_uid', - email: 'email', - name: 'name', - picture: 'avatar_url', - }, - }) - claimMapping: Record; - - // 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, + type: 'timestamptz', nullable: true, - name: 'allowed_domains', + name: 'linked_at', + default: () => 'CURRENT_TIMESTAMP', }) - allowedDomains: string[] | null; + linkedAt: Date | null; - @Column({ - type: 'boolean', - default: false, - nullable: false, - name: 'auto_create_users', + @Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' }) + lastUsedAt: Date | null; + + @Column({ type: 'timestamptz', nullable: true, name: 'unlinked_at' }) + 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' }) - defaultRoleId: string | null; - - // Relaciones - @ManyToOne(() => Tenant, { onDelete: 'CASCADE', nullable: true }) + @ManyToOne(() => Tenant, { + onDelete: 'CASCADE', + }) @JoinColumn({ name: 'tenant_id' }) - tenant: Tenant | null; - - @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; + tenant: Tenant; } diff --git a/src/modules/auth/entities/password-reset.entity.ts b/src/modules/auth/entities/password-reset.entity.ts index 79ac700..a3ef55d 100644 --- a/src/modules/auth/entities/password-reset.entity.ts +++ b/src/modules/auth/entities/password-reset.entity.ts @@ -23,10 +23,10 @@ export class PasswordReset { @Column({ type: 'varchar', length: 500, unique: true, nullable: false }) token: string; - @Column({ type: 'timestamp', nullable: false, name: 'expires_at' }) + @Column({ type: 'timestamptz', nullable: false, name: 'expires_at' }) expiresAt: Date; - @Column({ type: 'timestamp', nullable: true, name: 'used_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'used_at' }) usedAt: Date | null; @Column({ type: 'inet', nullable: true, name: 'ip_address' }) @@ -40,6 +40,6 @@ export class PasswordReset { user: User; // Timestamps - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; } diff --git a/src/modules/auth/entities/permission.entity.ts b/src/modules/auth/entities/permission.entity.ts index e67566c..4ced8f5 100644 --- a/src/modules/auth/entities/permission.entity.ts +++ b/src/modules/auth/entities/permission.entity.ts @@ -5,48 +5,67 @@ import { CreateDateColumn, Index, ManyToMany, + Unique, } from 'typeorm'; 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 { CREATE = 'create', READ = 'read', UPDATE = 'update', DELETE = 'delete', - APPROVE = 'approve', - CANCEL = 'cancel', 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_action', ['action']) -@Index('idx_permissions_module', ['module']) +@Index('idx_permissions_category', ['category']) export class Permission { @PrimaryGeneratedColumn('uuid') id: string; + // Identificacion @Column({ type: 'varchar', length: 100, nullable: false }) resource: string; - @Column({ - type: 'enum', - enum: PermissionAction, - nullable: false, - }) - action: PermissionAction; + @Column({ type: 'varchar', length: 50, nullable: false }) + action: string; + + @Column({ type: 'varchar', length: 50, default: "'own'", nullable: true }) + scope: string; + + // Info + @Column({ type: 'varchar', length: 255, nullable: true, name: 'display_name' }) + displayName: string | null; @Column({ type: 'text', nullable: true }) description: string | null; - @Column({ type: 'varchar', length: 50, nullable: true }) - module: string | null; + @Column({ type: 'varchar', length: 100, nullable: true }) + 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) roles: Role[]; - - // Sin tenant_id: permisos son globales - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) - createdAt: Date; } diff --git a/src/modules/auth/entities/role.entity.ts b/src/modules/auth/entities/role.entity.ts index 670c7e6..25de6f5 100644 --- a/src/modules/auth/entities/role.entity.ts +++ b/src/modules/auth/entities/role.entity.ts @@ -9,48 +9,111 @@ import { ManyToMany, JoinColumn, JoinTable, + OneToMany, } from 'typeorm'; import { Tenant } from './tenant.entity.js'; import { User } from './user.entity.js'; import { Permission } from './permission.entity.js'; -@Entity({ schema: 'auth', name: 'roles' }) -@Index('idx_roles_tenant_id', ['tenantId']) -@Index('idx_roles_code', ['code']) -@Index('idx_roles_is_system', ['isSystem']) +/** + * Entity: users.roles + * DDL source: database/ddl/07-users-rbac.sql + * + * 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 { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) - tenantId: string; + @Column({ type: 'uuid', nullable: true, name: 'tenant_id' }) + tenantId: string | null; + // Info basica @Column({ type: 'varchar', length: 100, nullable: false }) name: string; - @Column({ type: 'varchar', length: 50, nullable: false }) - code: string; + @Column({ type: 'varchar', length: 255, nullable: true, name: 'display_name' }) + displayName: string | null; @Column({ type: 'text', nullable: true }) description: string | null; - @Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' }) - isSystem: boolean; - @Column({ type: 'varchar', length: 20, nullable: true }) 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; + + // 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, { onDelete: 'CASCADE', + nullable: true, }) @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) @JoinTable({ name: 'role_permissions', - schema: 'auth', + schema: 'users', joinColumn: { name: 'role_id', referencedColumnName: 'id' }, inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' }, }) @@ -58,27 +121,4 @@ export class Role { @ManyToMany(() => User, (user) => user.roles) 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; } diff --git a/src/modules/auth/entities/session.entity.ts b/src/modules/auth/entities/session.entity.ts index b34c19d..55d6fe4 100644 --- a/src/modules/auth/entities/session.entity.ts +++ b/src/modules/auth/entities/session.entity.ts @@ -8,18 +8,25 @@ import { JoinColumn, } from 'typeorm'; 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 { ACTIVE = 'active', - EXPIRED = 'expired', REVOKED = 'revoked', + EXPIRED = 'expired', } @Entity({ schema: 'auth', name: 'sessions' }) -@Index('idx_sessions_user_id', ['userId']) -@Index('idx_sessions_token', ['token']) -@Index('idx_sessions_status', ['status']) -@Index('idx_sessions_expires_at', ['expiresAt']) +@Index('idx_sessions_user', ['userId']) +@Index('idx_sessions_tenant', ['tenantId']) +@Index('idx_sessions_jti', ['jti']) +@Index('idx_sessions_expires', ['expiresAt']) +@Index('idx_sessions_active', ['userId', 'revokedAt']) export class Session { @PrimaryGeneratedColumn('uuid') id: string; @@ -27,57 +34,64 @@ export class Session { @Column({ type: 'uuid', nullable: false, name: 'user_id' }) userId: string; - @Column({ type: 'varchar', length: 500, unique: true, nullable: false }) - token: string; + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + // Token info + @Column({ + type: 'varchar', + length: 255, + nullable: false, + name: 'refresh_token_hash', + }) + refreshTokenHash: string; @Column({ type: 'varchar', - length: 500, + length: 255, unique: true, - nullable: true, - name: 'refresh_token', - }) - refreshToken: string | null; - - @Column({ - type: 'enum', - enum: SessionStatus, - default: SessionStatus.ACTIVE, nullable: false, }) - status: SessionStatus; + jti: string; - @Column({ type: 'timestamp', nullable: false, name: 'expires_at' }) - expiresAt: Date; + // Device info + @Column({ type: 'jsonb', default: '{}', name: 'device_info' }) + deviceInfo: Record; @Column({ - type: 'timestamp', + type: 'varchar', + length: 255, nullable: true, - name: 'refresh_expires_at', + name: 'device_fingerprint', }) - refreshExpiresAt: Date | null; - - @Column({ type: 'inet', nullable: true, name: 'ip_address' }) - ipAddress: string | null; + deviceFingerprint: string | null; @Column({ type: 'text', nullable: true, name: 'user_agent' }) userAgent: string | null; - @Column({ type: 'jsonb', nullable: true, name: 'device_info' }) - deviceInfo: Record | null; + @Column({ type: 'inet', nullable: true, name: 'ip_address' }) + ipAddress: string | null; - // Relaciones - @ManyToOne(() => User, (user) => user.sessions, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'user_id' }) - user: User; + // Geo info + @Column({ type: 'varchar', length: 2, nullable: true, name: 'country_code' }) + countryCode: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + city: string | null; // Timestamps - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) - createdAt: Date; + @Column({ + 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; @Column({ @@ -87,4 +101,20 @@ export class Session { name: 'revoked_reason', }) 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; } diff --git a/src/modules/auth/entities/tenant.entity.ts b/src/modules/auth/entities/tenant.entity.ts index 2d0d447..e0e4291 100644 --- a/src/modules/auth/entities/tenant.entity.ts +++ b/src/modules/auth/entities/tenant.entity.ts @@ -69,7 +69,7 @@ export class Tenant { roles: Role[]; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -77,7 +77,7 @@ export class Tenant { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; @@ -85,7 +85,7 @@ export class Tenant { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' }) deletedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) diff --git a/src/modules/auth/entities/token-blacklist.entity.ts b/src/modules/auth/entities/token-blacklist.entity.ts new file mode 100644 index 0000000..bd81988 --- /dev/null +++ b/src/modules/auth/entities/token-blacklist.entity.ts @@ -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; +} diff --git a/src/modules/auth/entities/user-profile-assignment.entity.ts b/src/modules/auth/entities/user-profile-assignment.entity.ts index 5bbe58b..04b7570 100644 --- a/src/modules/auth/entities/user-profile-assignment.entity.ts +++ b/src/modules/auth/entities/user-profile-assignment.entity.ts @@ -23,7 +23,7 @@ export class UserProfileAssignment { @Column({ name: 'is_default', default: false }) isDefault: boolean; - @CreateDateColumn({ name: 'assigned_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'assigned_at', type: 'timestamptz' }) assignedAt: Date; @ManyToOne(() => User, { onDelete: 'CASCADE' }) diff --git a/src/modules/auth/entities/user-profile.entity.ts b/src/modules/auth/entities/user-profile.entity.ts index 400b28f..1d9a0b8 100644 --- a/src/modules/auth/entities/user-profile.entity.ts +++ b/src/modules/auth/entities/user-profile.entity.ts @@ -34,10 +34,10 @@ export class UserProfile { @Column({ name: 'is_active', default: true }) isActive: boolean; - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; - @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) updatedAt: Date; @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) diff --git a/src/modules/auth/entities/user.entity.ts b/src/modules/auth/entities/user.entity.ts index f141dd4..d3c34b7 100644 --- a/src/modules/auth/entities/user.entity.ts +++ b/src/modules/auth/entities/user.entity.ts @@ -79,13 +79,13 @@ export class User { oauthProviderId: string; @Column({ - type: 'timestamp', + type: 'timestamptz', nullable: true, name: 'email_verified_at', }) emailVerifiedAt: Date | null; - @Column({ type: 'timestamp', nullable: true, name: 'last_login_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'last_login_at' }) lastLoginAt: Date | null; @Column({ type: 'inet', nullable: true, name: 'last_login_ip' }) @@ -113,7 +113,7 @@ export class User { @ManyToMany(() => Role, (role) => role.users) @JoinTable({ name: 'user_roles', - schema: 'auth', + schema: 'users', joinColumn: { name: 'user_id', referencedColumnName: 'id' }, inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' }, }) @@ -135,7 +135,7 @@ export class User { passwordResets: PasswordReset[]; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -143,7 +143,7 @@ export class User { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; @@ -151,7 +151,7 @@ export class User { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' }) deletedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) diff --git a/src/modules/auth/services/token.service.ts b/src/modules/auth/services/token.service.ts index ee671ba..7a2b6df 100644 --- a/src/modules/auth/services/token.service.ts +++ b/src/modules/auth/services/token.service.ts @@ -1,6 +1,6 @@ import jwt, { SignOptions } from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; -import { Repository } from 'typeorm'; +import { IsNull, Not, Repository } from 'typeorm'; import { AppDataSource } from '../../../config/typeorm.js'; import { config } from '../../../config/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 }); // 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 const accessTokenExpiresAt = this.calculateExpiration(this.ACCESS_TOKEN_EXPIRY); @@ -102,11 +102,10 @@ class TokenService { // Create session record in database const session = this.sessionRepository.create({ userId: user.id, - token: accessJti, // Store JTI instead of full token - refreshToken: refreshJti, // Store JTI instead of full token - status: SessionStatus.ACTIVE, - expiresAt: accessTokenExpiresAt, - refreshExpiresAt: refreshTokenExpiresAt, + tenantId: user.tenantId, + jti: accessJti, + refreshTokenHash: refreshJti, + expiresAt: refreshTokenExpiresAt, ipAddress: metadata.ipAddress, userAgent: metadata.userAgent, }); @@ -153,8 +152,8 @@ class TokenService { // Find active session with this refresh token JTI const session = await this.sessionRepository.findOne({ where: { - refreshToken: payload.jti, - status: SessionStatus.ACTIVE, + refreshTokenHash: payload.jti, + revokedAt: IsNull(), }, relations: ['user', 'user.roles'], }); @@ -189,10 +188,10 @@ class TokenService { } // Verify session hasn't expired - if (session.refreshExpiresAt && new Date() > session.refreshExpiresAt) { + if (session.expiresAt && new Date() > session.expiresAt) { logger.warn('Refresh token expired', { sessionId: session.id, - expiredAt: session.refreshExpiresAt, + expiredAt: session.expiresAt, }); await this.revokeSession(session.id, 'Token expired'); @@ -200,7 +199,6 @@ class TokenService { } // Mark current session as used (revoke it) - session.status = SessionStatus.REVOKED; session.revokedAt = new Date(); session.revokedReason = 'Used for refresh'; await this.sessionRepository.save(session); @@ -242,7 +240,6 @@ class TokenService { } // Mark session as revoked - session.status = SessionStatus.REVOKED; session.revokedAt = new Date(); session.revokedReason = reason; await this.sessionRepository.save(session); @@ -250,7 +247,7 @@ class TokenService { // Blacklist the access token (JTI) in Redis const remainingTTL = this.calculateRemainingTTL(session.expiresAt); if (remainingTTL > 0) { - await this.blacklistAccessToken(session.token, remainingTTL); + await this.blacklistAccessToken(session.jti, remainingTTL); } logger.info('Session revoked successfully', { sessionId, reason }); @@ -277,7 +274,7 @@ class TokenService { const sessions = await this.sessionRepository.find({ where: { userId, - status: SessionStatus.ACTIVE, + revokedAt: IsNull(), }, }); @@ -288,14 +285,13 @@ class TokenService { // Revoke each session for (const session of sessions) { - session.status = SessionStatus.REVOKED; session.revokedAt = new Date(); session.revokedReason = reason; // Blacklist access token const remainingTTL = this.calculateRemainingTTL(session.expiresAt); if (remainingTTL > 0) { - await this.blacklistAccessToken(session.token, remainingTTL); + await this.blacklistAccessToken(session.jti, remainingTTL); } } diff --git a/src/modules/cfdi/cfdi.controller.ts b/src/modules/cfdi/cfdi.controller.ts new file mode 100644 index 0000000..34375d9 --- /dev/null +++ b/src/modules/cfdi/cfdi.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/cfdi/cfdi.module.ts b/src/modules/cfdi/cfdi.module.ts new file mode 100644 index 0000000..094a0f4 --- /dev/null +++ b/src/modules/cfdi/cfdi.module.ts @@ -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'; diff --git a/src/modules/cfdi/cfdi.routes.ts b/src/modules/cfdi/cfdi.routes.ts new file mode 100644 index 0000000..db9b950 --- /dev/null +++ b/src/modules/cfdi/cfdi.routes.ts @@ -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; diff --git a/src/modules/cfdi/dto/cancel-invoice.dto.ts b/src/modules/cfdi/dto/cancel-invoice.dto.ts new file mode 100644 index 0000000..a96e3be --- /dev/null +++ b/src/modules/cfdi/dto/cancel-invoice.dto.ts @@ -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; +} diff --git a/src/modules/cfdi/dto/create-certificate.dto.ts b/src/modules/cfdi/dto/create-certificate.dto.ts new file mode 100644 index 0000000..685e7d8 --- /dev/null +++ b/src/modules/cfdi/dto/create-certificate.dto.ts @@ -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; +} diff --git a/src/modules/cfdi/dto/index.ts b/src/modules/cfdi/dto/index.ts new file mode 100644 index 0000000..802a41e --- /dev/null +++ b/src/modules/cfdi/dto/index.ts @@ -0,0 +1,3 @@ +export * from './create-certificate.dto.js'; +export * from './stamp-invoice.dto.js'; +export * from './cancel-invoice.dto.js'; diff --git a/src/modules/cfdi/dto/stamp-invoice.dto.ts b/src/modules/cfdi/dto/stamp-invoice.dto.ts new file mode 100644 index 0000000..c66bc6b --- /dev/null +++ b/src/modules/cfdi/dto/stamp-invoice.dto.ts @@ -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; + + @IsObject() + @IsOptional() + complementos?: Record; + + @IsObject() + @IsOptional() + metadata?: Record; +} + +/** + * 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; +} diff --git a/src/modules/cfdi/entities/cfdi-cancellation.entity.ts b/src/modules/cfdi/entities/cfdi-cancellation.entity.ts new file mode 100644 index 0000000..e974571 --- /dev/null +++ b/src/modules/cfdi/entities/cfdi-cancellation.entity.ts @@ -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 | 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; +} diff --git a/src/modules/cfdi/entities/cfdi-certificate.entity.ts b/src/modules/cfdi/entities/cfdi-certificate.entity.ts new file mode 100644 index 0000000..234f020 --- /dev/null +++ b/src/modules/cfdi/entities/cfdi-certificate.entity.ts @@ -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; +} diff --git a/src/modules/cfdi/entities/cfdi-invoice.entity.ts b/src/modules/cfdi/entities/cfdi-invoice.entity.ts new file mode 100644 index 0000000..e162ffd --- /dev/null +++ b/src/modules/cfdi/entities/cfdi-invoice.entity.ts @@ -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 | 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 | 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; +} diff --git a/src/modules/cfdi/entities/cfdi-log.entity.ts b/src/modules/cfdi/entities/cfdi-log.entity.ts new file mode 100644 index 0000000..014dc1f --- /dev/null +++ b/src/modules/cfdi/entities/cfdi-log.entity.ts @@ -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 | null; + + @Column({ type: 'jsonb', nullable: true, name: 'request_payload' }) + requestPayload: Record | null; + + @Column({ type: 'jsonb', nullable: true, name: 'response_payload' }) + responsePayload: Record | 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; +} diff --git a/src/modules/cfdi/entities/cfdi-pac-configuration.entity.ts b/src/modules/cfdi/entities/cfdi-pac-configuration.entity.ts new file mode 100644 index 0000000..bcd07d4 --- /dev/null +++ b/src/modules/cfdi/entities/cfdi-pac-configuration.entity.ts @@ -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; +} diff --git a/src/modules/cfdi/entities/cfdi-payment-complement.entity.ts b/src/modules/cfdi/entities/cfdi-payment-complement.entity.ts new file mode 100644 index 0000000..0332b40 --- /dev/null +++ b/src/modules/cfdi/entities/cfdi-payment-complement.entity.ts @@ -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; +} diff --git a/src/modules/cfdi/entities/cfdi-stamp-queue.entity.ts b/src/modules/cfdi/entities/cfdi-stamp-queue.entity.ts new file mode 100644 index 0000000..cb14a51 --- /dev/null +++ b/src/modules/cfdi/entities/cfdi-stamp-queue.entity.ts @@ -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; +} diff --git a/src/modules/cfdi/entities/index.ts b/src/modules/cfdi/entities/index.ts new file mode 100644 index 0000000..0d57893 --- /dev/null +++ b/src/modules/cfdi/entities/index.ts @@ -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'; diff --git a/src/modules/cfdi/enums/cancellation-reason.enum.ts b/src/modules/cfdi/enums/cancellation-reason.enum.ts new file mode 100644 index 0000000..27d0d2f --- /dev/null +++ b/src/modules/cfdi/enums/cancellation-reason.enum.ts @@ -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.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', +}; diff --git a/src/modules/cfdi/enums/cfdi-status.enum.ts b/src/modules/cfdi/enums/cfdi-status.enum.ts new file mode 100644 index 0000000..f8ccdfd --- /dev/null +++ b/src/modules/cfdi/enums/cfdi-status.enum.ts @@ -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', +} diff --git a/src/modules/cfdi/enums/cfdi-type.enum.ts b/src/modules/cfdi/enums/cfdi-type.enum.ts new file mode 100644 index 0000000..817a348 --- /dev/null +++ b/src/modules/cfdi/enums/cfdi-type.enum.ts @@ -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.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', +} diff --git a/src/modules/cfdi/enums/index.ts b/src/modules/cfdi/enums/index.ts new file mode 100644 index 0000000..7d22299 --- /dev/null +++ b/src/modules/cfdi/enums/index.ts @@ -0,0 +1,3 @@ +export * from './cfdi-status.enum.js'; +export * from './cancellation-reason.enum.js'; +export * from './cfdi-type.enum.js'; diff --git a/src/modules/cfdi/interfaces/finkok-response.interface.ts b/src/modules/cfdi/interfaces/finkok-response.interface.ts new file mode 100644 index 0000000..2fa953d --- /dev/null +++ b/src/modules/cfdi/interfaces/finkok-response.interface.ts @@ -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; +} diff --git a/src/modules/cfdi/interfaces/index.ts b/src/modules/cfdi/interfaces/index.ts new file mode 100644 index 0000000..0412a31 --- /dev/null +++ b/src/modules/cfdi/interfaces/index.ts @@ -0,0 +1 @@ +export * from './finkok-response.interface.js'; diff --git a/src/modules/cfdi/services/cfdi.service.ts b/src/modules/cfdi/services/cfdi.service.ts new file mode 100644 index 0000000..4c124ad --- /dev/null +++ b/src/modules/cfdi/services/cfdi.service.ts @@ -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 | null; + lastError: string | null; +} + +// ===== CfdiService Class ===== + +class CfdiService { + private invoiceRepository: Repository; + private certificateRepository: Repository; + private cancellationRepository: Repository; + private logRepository: Repository; + private stampQueueRepository: Repository; + + 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 { + 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 { + try { + const invoiceData: Partial = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/cfdi/services/index.ts b/src/modules/cfdi/services/index.ts new file mode 100644 index 0000000..847b687 --- /dev/null +++ b/src/modules/cfdi/services/index.ts @@ -0,0 +1,10 @@ +export { + cfdiService, + CreateCfdiInvoiceDto, + UpdateCfdiInvoiceDto, + CfdiFilters, + CertificateFilters, + UploadCertificateDto, + CancellationRequestDto, + CfdiStatusResponse, +} from './cfdi.service.js'; diff --git a/src/modules/core/entities/country.entity.ts b/src/modules/core/entities/country.entity.ts index e3a6384..551809c 100644 --- a/src/modules/core/entities/country.entity.ts +++ b/src/modules/core/entities/country.entity.ts @@ -30,6 +30,6 @@ export class Country { currencyCode: string | null; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; } diff --git a/src/modules/core/entities/currency.entity.ts b/src/modules/core/entities/currency.entity.ts index f322222..af89f10 100644 --- a/src/modules/core/entities/currency.entity.ts +++ b/src/modules/core/entities/currency.entity.ts @@ -38,6 +38,6 @@ export class Currency { active: boolean; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; } diff --git a/src/modules/core/entities/discount-rule.entity.ts b/src/modules/core/entities/discount-rule.entity.ts index 1454a4a..eb7bcd5 100644 --- a/src/modules/core/entities/discount-rule.entity.ts +++ b/src/modules/core/entities/discount-rule.entity.ts @@ -121,10 +121,10 @@ export class DiscountRule { }) conditionValue: number | null; - @Column({ type: 'timestamp', nullable: true, name: 'start_date' }) + @Column({ type: 'timestamptz', nullable: true, name: 'start_date' }) startDate: Date | null; - @Column({ type: 'timestamp', nullable: true, name: 'end_date' }) + @Column({ type: 'timestamptz', nullable: true, name: 'end_date' }) endDate: Date | null; @Column({ type: 'integer', nullable: false, default: 10 }) @@ -143,13 +143,13 @@ export class DiscountRule { isActive: boolean; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) createdBy: string | null; - @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) updatedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) diff --git a/src/modules/core/entities/payment-term.entity.ts b/src/modules/core/entities/payment-term.entity.ts index 38c3e17..19ea4c9 100644 --- a/src/modules/core/entities/payment-term.entity.ts +++ b/src/modules/core/entities/payment-term.entity.ts @@ -124,13 +124,13 @@ export class PaymentTerm { lines: PaymentTermLine[]; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) createdBy: string | null; - @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) updatedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) diff --git a/src/modules/core/entities/product-category.entity.ts b/src/modules/core/entities/product-category.entity.ts index d9fdd08..8d034bd 100644 --- a/src/modules/core/entities/product-category.entity.ts +++ b/src/modules/core/entities/product-category.entity.ts @@ -55,7 +55,7 @@ export class ProductCategory { children: ProductCategory[]; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -63,7 +63,7 @@ export class ProductCategory { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; @@ -71,7 +71,7 @@ export class ProductCategory { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' }) deletedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) diff --git a/src/modules/core/entities/sequence.entity.ts b/src/modules/core/entities/sequence.entity.ts index cc28829..55c3c2c 100644 --- a/src/modules/core/entities/sequence.entity.ts +++ b/src/modules/core/entities/sequence.entity.ts @@ -55,7 +55,7 @@ export class Sequence { resetPeriod: ResetPeriod | null; @Column({ - type: 'timestamp', + type: 'timestamptz', nullable: true, name: 'last_reset_date', }) @@ -65,7 +65,7 @@ export class Sequence { isActive: boolean; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -73,7 +73,7 @@ export class Sequence { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; diff --git a/src/modules/financial/entities/account.entity.ts b/src/modules/financial/entities/account.entity.ts index 5db7d67..621b1dd 100644 --- a/src/modules/financial/entities/account.entity.ts +++ b/src/modules/financial/entities/account.entity.ts @@ -69,7 +69,7 @@ export class Account { children: Account[]; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -77,7 +77,7 @@ export class Account { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; @@ -85,7 +85,7 @@ export class Account { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' }) deletedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) diff --git a/src/modules/financial/entities/fiscal-period.entity.ts b/src/modules/financial/entities/fiscal-period.entity.ts index b3f92a3..1c81b9c 100644 --- a/src/modules/financial/entities/fiscal-period.entity.ts +++ b/src/modules/financial/entities/fiscal-period.entity.ts @@ -44,7 +44,7 @@ export class FiscalPeriod { }) status: FiscalPeriodStatus; - @Column({ type: 'timestamp', nullable: true, name: 'closed_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'closed_at' }) closedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'closed_by' }) @@ -56,7 +56,7 @@ export class FiscalPeriod { fiscalYear: FiscalYear; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) diff --git a/src/modules/financial/entities/fiscal-year.entity.ts b/src/modules/financial/entities/fiscal-year.entity.ts index 7a7866e..1fdc215 100644 --- a/src/modules/financial/entities/fiscal-year.entity.ts +++ b/src/modules/financial/entities/fiscal-year.entity.ts @@ -59,7 +59,7 @@ export class FiscalYear { periods: FiscalPeriod[]; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) diff --git a/src/modules/financial/entities/index.ts b/src/modules/financial/entities/index.ts index df67f1c..d96e2b4 100644 --- a/src/modules/financial/entities/index.ts +++ b/src/modules/financial/entities/index.ts @@ -14,6 +14,7 @@ export { InvoiceLine } from './invoice-line.entity.js'; // Payment entities export { Payment, PaymentType, PaymentMethod, PaymentStatus } from './payment.entity.js'; +export { PaymentInvoiceAllocation } from './payment-invoice-allocation.entity.js'; // Tax entities export { Tax, TaxType } from './tax.entity.js'; diff --git a/src/modules/financial/entities/invoice-line.entity.ts b/src/modules/financial/entities/invoice-line.entity.ts index 33f875f..ba02258 100644 --- a/src/modules/financial/entities/invoice-line.entity.ts +++ b/src/modules/financial/entities/invoice-line.entity.ts @@ -67,12 +67,12 @@ export class InvoiceLine { account: Account | null; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; diff --git a/src/modules/financial/entities/invoice.entity.ts b/src/modules/financial/entities/invoice.entity.ts index 3f98a19..45f60f2 100644 --- a/src/modules/financial/entities/invoice.entity.ts +++ b/src/modules/financial/entities/invoice.entity.ts @@ -122,7 +122,7 @@ export class Invoice { lines: InvoiceLine[]; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -130,7 +130,7 @@ export class Invoice { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; @@ -138,13 +138,13 @@ export class Invoice { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'validated_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'validated_at' }) validatedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'validated_by' }) validatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'cancelled_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'cancelled_at' }) cancelledAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'cancelled_by' }) diff --git a/src/modules/financial/entities/journal-entry-line.entity.ts b/src/modules/financial/entities/journal-entry-line.entity.ts index 7fd8fd1..958ddfd 100644 --- a/src/modules/financial/entities/journal-entry-line.entity.ts +++ b/src/modules/financial/entities/journal-entry-line.entity.ts @@ -54,6 +54,6 @@ export class JournalEntryLine { account: Account; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; } diff --git a/src/modules/financial/entities/journal-entry.entity.ts b/src/modules/financial/entities/journal-entry.entity.ts index 4513a1d..52d9d76 100644 --- a/src/modules/financial/entities/journal-entry.entity.ts +++ b/src/modules/financial/entities/journal-entry.entity.ts @@ -74,7 +74,7 @@ export class JournalEntry { lines: JournalEntryLine[]; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -82,7 +82,7 @@ export class JournalEntry { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; @@ -90,13 +90,13 @@ export class JournalEntry { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'posted_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'posted_at' }) postedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'posted_by' }) postedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'cancelled_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'cancelled_at' }) cancelledAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'cancelled_by' }) diff --git a/src/modules/financial/entities/journal.entity.ts b/src/modules/financial/entities/journal.entity.ts index 6a09088..9e27bc0 100644 --- a/src/modules/financial/entities/journal.entity.ts +++ b/src/modules/financial/entities/journal.entity.ts @@ -70,7 +70,7 @@ export class Journal { defaultAccount: Account | null; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -78,7 +78,7 @@ export class Journal { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; @@ -86,7 +86,7 @@ export class Journal { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' }) deletedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) diff --git a/src/modules/financial/entities/payment-invoice-allocation.entity.ts b/src/modules/financial/entities/payment-invoice-allocation.entity.ts new file mode 100644 index 0000000..4eaa4c5 --- /dev/null +++ b/src/modules/financial/entities/payment-invoice-allocation.entity.ts @@ -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; +} diff --git a/src/modules/financial/entities/payment.entity.ts b/src/modules/financial/entities/payment.entity.ts index e1ca757..0617054 100644 --- a/src/modules/financial/entities/payment.entity.ts +++ b/src/modules/financial/entities/payment.entity.ts @@ -111,7 +111,7 @@ export class Payment { journalEntry: JournalEntry | null; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -119,7 +119,7 @@ export class Payment { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; @@ -127,7 +127,7 @@ export class Payment { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'posted_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'posted_at' }) postedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'posted_by' }) diff --git a/src/modules/financial/entities/tax.entity.ts b/src/modules/financial/entities/tax.entity.ts index ca490a5..2a26036 100644 --- a/src/modules/financial/entities/tax.entity.ts +++ b/src/modules/financial/entities/tax.entity.ts @@ -60,7 +60,7 @@ export class Tax { company: Company; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -68,7 +68,7 @@ export class Tax { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; diff --git a/src/modules/inventory/entities/inventory-adjustment-line.entity.ts b/src/modules/inventory/entities/inventory-adjustment-line.entity.ts index 870c1ba..21d5494 100644 --- a/src/modules/inventory/entities/inventory-adjustment-line.entity.ts +++ b/src/modules/inventory/entities/inventory-adjustment-line.entity.ts @@ -75,6 +75,6 @@ export class InventoryAdjustmentLine { lot: Lot | null; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; } diff --git a/src/modules/inventory/entities/inventory-adjustment.entity.ts b/src/modules/inventory/entities/inventory-adjustment.entity.ts index 2ad84a9..071795e 100644 --- a/src/modules/inventory/entities/inventory-adjustment.entity.ts +++ b/src/modules/inventory/entities/inventory-adjustment.entity.ts @@ -68,7 +68,7 @@ export class InventoryAdjustment { lines: InventoryAdjustmentLine[]; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -76,7 +76,7 @@ export class InventoryAdjustment { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; diff --git a/src/modules/inventory/entities/location.entity.ts b/src/modules/inventory/entities/location.entity.ts index 28dcc57..c58924e 100644 --- a/src/modules/inventory/entities/location.entity.ts +++ b/src/modules/inventory/entities/location.entity.ts @@ -78,7 +78,7 @@ export class Location { stockQuants: StockQuant[]; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -86,7 +86,7 @@ export class Location { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; diff --git a/src/modules/inventory/entities/lot.entity.ts b/src/modules/inventory/entities/lot.entity.ts index aaed4be..7c132d2 100644 --- a/src/modules/inventory/entities/lot.entity.ts +++ b/src/modules/inventory/entities/lot.entity.ts @@ -56,7 +56,7 @@ export class Lot { stockQuants: StockQuant[]; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) diff --git a/src/modules/inventory/entities/picking.entity.ts b/src/modules/inventory/entities/picking.entity.ts index 9254b6a..d4b21aa 100644 --- a/src/modules/inventory/entities/picking.entity.ts +++ b/src/modules/inventory/entities/picking.entity.ts @@ -64,10 +64,10 @@ export class Picking { @Column({ type: 'uuid', nullable: true, name: 'partner_id' }) partnerId: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'scheduled_date' }) + @Column({ type: 'timestamptz', nullable: true, name: 'scheduled_date' }) scheduledDate: Date | null; - @Column({ type: 'timestamp', nullable: true, name: 'date_done' }) + @Column({ type: 'timestamptz', nullable: true, name: 'date_done' }) dateDone: Date | null; @Column({ type: 'varchar', length: 255, nullable: true }) @@ -84,7 +84,7 @@ export class Picking { @Column({ type: 'text', nullable: true }) notes: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'validated_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'validated_at' }) validatedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'validated_by' }) @@ -107,7 +107,7 @@ export class Picking { moves: StockMove[]; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -115,7 +115,7 @@ export class Picking { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; diff --git a/src/modules/inventory/entities/product.entity.ts b/src/modules/inventory/entities/product.entity.ts index 85a159a..9f64ef5 100644 --- a/src/modules/inventory/entities/product.entity.ts +++ b/src/modules/inventory/entities/product.entity.ts @@ -147,7 +147,7 @@ export class Product { lots: Lot[]; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -155,7 +155,7 @@ export class Product { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; @@ -163,7 +163,7 @@ export class Product { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' }) deletedAt: Date | null; @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) diff --git a/src/modules/inventory/entities/stock-move.entity.ts b/src/modules/inventory/entities/stock-move.entity.ts index c6c8988..c21b650 100644 --- a/src/modules/inventory/entities/stock-move.entity.ts +++ b/src/modules/inventory/entities/stock-move.entity.ts @@ -58,7 +58,7 @@ export class StockMove { }) status: MoveStatus; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: 'timestamptz', nullable: true }) date: Date | null; @Column({ type: 'varchar', length: 255, nullable: true }) @@ -86,7 +86,7 @@ export class StockMove { lot: Lot | null; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -94,7 +94,7 @@ export class StockMove { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; diff --git a/src/modules/inventory/entities/stock-quant.entity.ts b/src/modules/inventory/entities/stock-quant.entity.ts index 3111644..680a71e 100644 --- a/src/modules/inventory/entities/stock-quant.entity.ts +++ b/src/modules/inventory/entities/stock-quant.entity.ts @@ -54,12 +54,12 @@ export class StockQuant { lot: Lot | null; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; diff --git a/src/modules/inventory/entities/stock-valuation-layer.entity.ts b/src/modules/inventory/entities/stock-valuation-layer.entity.ts index 25712d0..2f7ce21 100644 --- a/src/modules/inventory/entities/stock-valuation-layer.entity.ts +++ b/src/modules/inventory/entities/stock-valuation-layer.entity.ts @@ -67,7 +67,7 @@ export class StockValuationLayer { company: Company; // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -75,7 +75,7 @@ export class StockValuationLayer { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; diff --git a/src/modules/invoices/DEPRECATED.md b/src/modules/invoices/DEPRECATED.md index ed621b8..0e13dbe 100644 --- a/src/modules/invoices/DEPRECATED.md +++ b/src/modules/invoices/DEPRECATED.md @@ -5,13 +5,13 @@ Este modulo esta deprecated desde 2026-02-03. ## Razon 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 residir en un unico modulo. ## Mapeo de Entidades -| Invoices (billing) | Financial (financial) | Notas | +| Invoices (operations) | Financial (financial) | Notas | |------------------------|------------------------------|------------------------------------------| | Invoice | financial/invoice.entity.ts | financial tiene integracion con journals | | InvoiceItem | financial/invoice-line.entity.ts | Renombrado a InvoiceLine | @@ -21,8 +21,8 @@ residir en un unico modulo. ## Diferencias Clave ### Invoices (deprecated) -- Schema: `billing` -- Enfoque: Facturacion operativa, CFDI Mexico, SaaS billing +- Schema: `operations` (moved from `billing` to resolve conflict with SaaS billing schema) +- Enfoque: Facturacion operativa, CFDI Mexico - Sin integracion contable directa ### Financial (activo) diff --git a/src/modules/invoices/entities/invoice-item.entity.ts b/src/modules/invoices/entities/invoice-item.entity.ts index 93d216f..59ef100 100644 --- a/src/modules/invoices/entities/invoice-item.entity.ts +++ b/src/modules/invoices/entities/invoice-item.entity.ts @@ -7,7 +7,7 @@ import { Invoice } from './invoice.entity'; * @deprecated Since 2026-02-03. Use financial/invoice-line.entity.ts instead. * @see InvoiceLine from '@modules/financial/entities' */ -@Entity({ name: 'invoice_items', schema: 'billing' }) +@Entity({ name: 'invoice_items', schema: 'operations' }) export class InvoiceItem { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts index 7d76636..01d6601 100644 --- a/src/modules/invoices/entities/invoice.entity.ts +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -14,7 +14,7 @@ import { InvoiceItem } from './invoice-item.entity'; * Unified Invoice Entity * * Combines fields from commercial invoices and SaaS billing invoices. - * Schema: billing + * Schema: operations * * Context discriminator: * - '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 InvoiceContext = 'commercial' | 'saas'; -@Entity({ name: 'invoices', schema: 'billing' }) +@Entity({ name: 'invoices', schema: 'operations' }) export class Invoice { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/invoices/entities/payment-allocation.entity.ts b/src/modules/invoices/entities/payment-allocation.entity.ts index 18a3235..a24ccbe 100644 --- a/src/modules/invoices/entities/payment-allocation.entity.ts +++ b/src/modules/invoices/entities/payment-allocation.entity.ts @@ -10,7 +10,7 @@ import { Invoice } from './invoice.entity'; * @deprecated Since 2026-02-03. Use payment_invoices relationship in financial module. * @see Payment from '@modules/financial/entities' */ -@Entity({ name: 'payment_allocations', schema: 'billing' }) +@Entity({ name: 'payment_allocations', schema: 'operations' }) export class PaymentAllocation { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/invoices/entities/payment.entity.ts b/src/modules/invoices/entities/payment.entity.ts index 9e3863b..786dc64 100644 --- a/src/modules/invoices/entities/payment.entity.ts +++ b/src/modules/invoices/entities/payment.entity.ts @@ -6,7 +6,7 @@ import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateCol * @deprecated Since 2026-02-03. Use financial/payment.entity.ts instead. * @see Payment from '@modules/financial/entities' */ -@Entity({ name: 'payments', schema: 'billing' }) +@Entity({ name: 'payments', schema: 'operations' }) export class Payment { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/mobile/mobile.controller.ts b/src/modules/mobile/mobile.controller.ts new file mode 100644 index 0000000..42c60b0 --- /dev/null +++ b/src/modules/mobile/mobile.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/mobile/mobile.routes.ts b/src/modules/mobile/mobile.routes.ts new file mode 100644 index 0000000..a95f1ec --- /dev/null +++ b/src/modules/mobile/mobile.routes.ts @@ -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; diff --git a/src/modules/mobile/services/index.ts b/src/modules/mobile/services/index.ts new file mode 100644 index 0000000..757b91c --- /dev/null +++ b/src/modules/mobile/services/index.ts @@ -0,0 +1,9 @@ +export { + mobileService, + CreateSessionDto, + SessionFilters, + RegisterPushTokenDto, + UpdatePushTokenDto, + AddToQueueDto, + QueueFilters, +} from './mobile.service.js'; diff --git a/src/modules/mobile/services/mobile.service.ts b/src/modules/mobile/services/mobile.service.ts new file mode 100644 index 0000000..347c1e9 --- /dev/null +++ b/src/modules/mobile/services/mobile.service.ts @@ -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; + metadata?: Record; + 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; + private pushTokenRepository: Repository; + private syncQueueRepository: Repository; + + 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 { + try { + const sessionData: Partial = { + 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 { + 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 { + 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 { + 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 { + 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 = { + 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 { + 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 { + 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 { + try { + const queueData: Partial = { + 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 { + 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 { + 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(); diff --git a/src/modules/partners/dto/create-partner.dto.ts b/src/modules/partners/dto/create-partner.dto.ts index 275501a..fbadd03 100644 --- a/src/modules/partners/dto/create-partner.dto.ts +++ b/src/modules/partners/dto/create-partner.dto.ts @@ -209,36 +209,21 @@ export class CreatePartnerAddressDto { partnerId: string; @IsOptional() - @IsEnum(['billing', 'shipping', 'both']) - addressType?: 'billing' | 'shipping' | 'both'; + @IsEnum(['billing', 'shipping', 'main', 'other']) + addressType?: 'billing' | 'shipping' | 'main' | 'other'; @IsOptional() @IsBoolean() isDefault?: boolean; - @IsOptional() - @IsString() - @MaxLength(100) - label?: string; - @IsString() @MaxLength(200) - street: string; + addressLine1: string; @IsOptional() @IsString() - @MaxLength(20) - exteriorNumber?: string; - - @IsOptional() - @IsString() - @MaxLength(20) - interiorNumber?: string; - - @IsOptional() - @IsString() - @MaxLength(100) - neighborhood?: string; + @MaxLength(200) + addressLine2?: string; @IsString() @MaxLength(100) @@ -247,21 +232,32 @@ export class CreatePartnerAddressDto { @IsOptional() @IsString() @MaxLength(100) - municipality?: string; + state?: string; + @IsOptional() @IsString() - @MaxLength(100) - state: string; - - @IsString() - @MaxLength(10) - postalCode: string; + @MaxLength(20) + postalCode?: string; @IsOptional() @IsString() @MaxLength(3) country?: string; + @IsOptional() + @IsString() + @MaxLength(100) + contactName?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + contactPhone?: string; + + @IsOptional() + @IsEmail() + contactEmail?: string; + @IsOptional() @IsString() reference?: string; @@ -273,6 +269,10 @@ export class CreatePartnerAddressDto { @IsOptional() @IsNumber() longitude?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; } export class CreatePartnerContactDto { @@ -281,12 +281,12 @@ export class CreatePartnerContactDto { @IsString() @MaxLength(200) - fullName: string; + name: string; @IsOptional() @IsString() @MaxLength(100) - position?: string; + jobTitle?: string; @IsOptional() @IsString() @@ -308,9 +308,8 @@ export class CreatePartnerContactDto { mobile?: string; @IsOptional() - @IsString() - @MaxLength(30) - extension?: string; + @IsEnum(['general', 'billing', 'purchasing', 'sales', 'technical']) + contactType?: 'general' | 'billing' | 'purchasing' | 'sales' | 'technical'; @IsOptional() @IsBoolean() @@ -318,15 +317,7 @@ export class CreatePartnerContactDto { @IsOptional() @IsBoolean() - isBillingContact?: boolean; - - @IsOptional() - @IsBoolean() - isShippingContact?: boolean; - - @IsOptional() - @IsBoolean() - receivesNotifications?: boolean; + isActive?: boolean; @IsOptional() @IsString() diff --git a/src/modules/partners/entities/partner-address.entity.ts b/src/modules/partners/entities/partner-address.entity.ts index 566becc..828ea81 100644 --- a/src/modules/partners/entities/partner-address.entity.ts +++ b/src/modules/partners/entities/partner-address.entity.ts @@ -4,18 +4,27 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, Index, ManyToOne, JoinColumn, } from 'typeorm'; 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' }) export class PartnerAddress { @PrimaryGeneratedColumn('uuid') id: string; - @Index() + @Index('idx_partner_addresses_partner') @Column({ name: 'partner_id', type: 'uuid' }) partnerId: string; @@ -24,54 +33,56 @@ export class PartnerAddress { partner: Partner; // Tipo de direccion - @Index() + @Index('idx_partner_addresses_type') @Column({ name: 'address_type', type: 'varchar', length: 20, default: 'billing' }) - addressType: 'billing' | 'shipping' | 'both'; - - @Column({ name: 'is_default', type: 'boolean', default: false }) - isDefault: boolean; + addressType: 'billing' | 'shipping' | 'main' | 'other'; // Direccion - @Column({ type: 'varchar', length: 100, nullable: true }) - label: string; + @Column({ name: 'address_line1', type: 'varchar', length: 200 }) + addressLine1: string; - @Column({ type: 'varchar', length: 200 }) - street: string; - - @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({ name: 'address_line2', type: 'varchar', length: 200, nullable: true }) + addressLine2: string | null; @Column({ type: 'varchar', length: 100 }) city: string; @Column({ type: 'varchar', length: 100, nullable: true }) - municipality: string; + state: string | null; - @Column({ type: 'varchar', length: 100 }) - state: string; - - @Column({ name: 'postal_code', type: 'varchar', length: 10 }) - postalCode: string; + @Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true }) + postalCode: string | null; @Column({ type: 'varchar', length: 3, default: 'MEX' }) 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 @Column({ type: 'text', nullable: true }) - reference: string; + reference: string | null; // Geolocalizacion @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) - latitude: number; + latitude: number | null; @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 @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @@ -79,4 +90,7 @@ export class PartnerAddress { @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; } diff --git a/src/modules/partners/entities/partner-contact.entity.ts b/src/modules/partners/entities/partner-contact.entity.ts index d4479fe..c2a0b47 100644 --- a/src/modules/partners/entities/partner-contact.entity.ts +++ b/src/modules/partners/entities/partner-contact.entity.ts @@ -4,18 +4,27 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, Index, ManyToOne, JoinColumn, } from 'typeorm'; 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' }) export class PartnerContact { @PrimaryGeneratedColumn('uuid') id: string; - @Index() + @Index('idx_partner_contacts_partner') @Column({ name: 'partner_id', type: 'uuid' }) partnerId: string; @@ -23,45 +32,42 @@ export class PartnerContact { @JoinColumn({ name: 'partner_id' }) partner: Partner; - // Datos del contacto - @Column({ name: 'full_name', type: 'varchar', length: 200 }) - fullName: string; + // Datos personales + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'job_title', type: 'varchar', length: 100, nullable: true }) + jobTitle: string | null; @Column({ type: 'varchar', length: 100, nullable: true }) - position: string; - - @Column({ type: 'varchar', length: 100, nullable: true }) - department: string; + department: string | null; // Contacto + @Index('idx_partner_contacts_email') @Column({ type: 'varchar', length: 255, nullable: true }) - email: string; + email: string | null; @Column({ type: 'varchar', length: 30, nullable: true }) - phone: string; + phone: string | null; @Column({ type: 'varchar', length: 30, nullable: true }) - mobile: string; + mobile: string | null; - @Column({ type: 'varchar', length: 30, nullable: true }) - extension: string; + // Rol + @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 }) isPrimary: boolean; - @Column({ name: 'is_billing_contact', type: 'boolean', default: false }) - isBillingContact: boolean; - - @Column({ name: 'is_shipping_contact', type: 'boolean', default: false }) - isShippingContact: boolean; - - @Column({ name: 'receives_notifications', type: 'boolean', default: true }) - receivesNotifications: boolean; + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; // Notas @Column({ type: 'text', nullable: true }) - notes: string; + notes: string | null; // Metadata @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @@ -69,4 +75,7 @@ export class PartnerContact { @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; } diff --git a/src/modules/projects/entities/index.ts b/src/modules/projects/entities/index.ts index 94033da..8db1056 100644 --- a/src/modules/projects/entities/index.ts +++ b/src/modules/projects/entities/index.ts @@ -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'; diff --git a/src/modules/projects/entities/milestone.entity.ts b/src/modules/projects/entities/milestone.entity.ts new file mode 100644 index 0000000..06e4254 --- /dev/null +++ b/src/modules/projects/entities/milestone.entity.ts @@ -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; +} diff --git a/src/modules/projects/entities/project-member.entity.ts b/src/modules/projects/entities/project-member.entity.ts new file mode 100644 index 0000000..65ad0e8 --- /dev/null +++ b/src/modules/projects/entities/project-member.entity.ts @@ -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; +} diff --git a/src/modules/projects/entities/project-stage.entity.ts b/src/modules/projects/entities/project-stage.entity.ts new file mode 100644 index 0000000..fc872c7 --- /dev/null +++ b/src/modules/projects/entities/project-stage.entity.ts @@ -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; +} diff --git a/src/modules/projects/entities/project.entity.ts b/src/modules/projects/entities/project.entity.ts new file mode 100644 index 0000000..85b3ff7 --- /dev/null +++ b/src/modules/projects/entities/project.entity.ts @@ -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; +} diff --git a/src/modules/projects/entities/task.entity.ts b/src/modules/projects/entities/task.entity.ts new file mode 100644 index 0000000..8221a3a --- /dev/null +++ b/src/modules/projects/entities/task.entity.ts @@ -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; +} diff --git a/src/modules/projects/entities/timesheet.entity.ts b/src/modules/projects/entities/timesheet.entity.ts index 3304761..ac1e7f2 100644 --- a/src/modules/projects/entities/timesheet.entity.ts +++ b/src/modules/projects/entities/timesheet.entity.ts @@ -73,11 +73,11 @@ export class TimesheetEntity { @Column({ type: 'uuid', nullable: true, name: 'approved_by' }) approvedBy: string | null; - @Column({ type: 'timestamp', nullable: true, name: 'approved_at' }) + @Column({ type: 'timestamptz', nullable: true, name: 'approved_at' }) approvedAt: Date | null; // Audit fields - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) @@ -85,7 +85,7 @@ export class TimesheetEntity { @UpdateDateColumn({ name: 'updated_at', - type: 'timestamp', + type: 'timestamptz', nullable: true, }) updatedAt: Date | null; diff --git a/src/modules/roles/permissions.controller.ts b/src/modules/roles/permissions.controller.ts index 71ad860..60c9aca 100644 --- a/src/modules/roles/permissions.controller.ts +++ b/src/modules/roles/permissions.controller.ts @@ -26,8 +26,8 @@ export class PermissionsController { const params: PaginationParams = { page, limit, sortBy, sortOrder }; // Build filter - const filter: { module?: string; resource?: string; action?: PermissionAction } = {}; - if (req.query.module) filter.module = req.query.module as string; + const filter: { category?: string; resource?: string; action?: PermissionAction } = {}; + 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.action) filter.action = req.query.action as PermissionAction; @@ -53,9 +53,9 @@ export class PermissionsController { /** * GET /permissions/modules - Get list of all modules */ - async getModules(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { - const modules = await permissionsService.getModules(); + const modules = await permissionsService.getCategories(); const response: ApiResponse = { success: true, @@ -91,7 +91,7 @@ export class PermissionsController { */ async getGrouped(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { - const grouped = await permissionsService.getGroupedByModule(); + const grouped = await permissionsService.getGroupedByCategory(); const response: ApiResponse = { success: true, @@ -107,10 +107,10 @@ export class PermissionsController { /** * GET /permissions/by-module/:module - Get all permissions for a module */ - async getByModule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + async getByCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { - const module = req.params.module; - const permissions = await permissionsService.getByModule(module); + const category = req.params.category; + const permissions = await permissionsService.getByCategory(category); const response: ApiResponse = { success: true, diff --git a/src/modules/roles/permissions.routes.ts b/src/modules/roles/permissions.routes.ts index 55c87e7..896a1ff 100644 --- a/src/modules/roles/permissions.routes.ts +++ b/src/modules/roles/permissions.routes.ts @@ -23,9 +23,9 @@ router.get('/', requireAccess({ roles: ['admin', 'manager'], permission: 'permis permissionsController.findAll(req, res, next) ); -// Get available modules (admin, manager) -router.get('/modules', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) => - permissionsController.getModules(req, res, next) +// Get available categories (admin, manager) +router.get('/categories', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) => + permissionsController.getCategories(req, res, next) ); // Get available resources (admin, manager) @@ -38,9 +38,9 @@ router.get('/grouped', requireAccess({ roles: ['admin', 'manager'], permission: permissionsController.getGrouped(req, res, next) ); -// Get permissions by module (admin, manager) -router.get('/by-module/:module', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) => - permissionsController.getByModule(req, res, next) +// Get permissions by category (admin, manager) +router.get('/by-category/:category', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) => + permissionsController.getByCategory(req, res, next) ); // Get permission matrix for admin UI (admin only) diff --git a/src/modules/roles/services/permissions.service.ts b/src/modules/roles/services/permissions.service.ts index f94754d..4f200e7 100644 --- a/src/modules/roles/services/permissions.service.ts +++ b/src/modules/roles/services/permissions.service.ts @@ -7,7 +7,7 @@ import { logger } from '../../../shared/utils/logger.js'; // ===== Interfaces ===== export interface PermissionFilter { - module?: string; + category?: string; resource?: string; action?: PermissionAction; } @@ -15,7 +15,7 @@ export interface PermissionFilter { export interface EffectivePermission { resource: string; action: string; - module: string | null; + category: string | null; fromRoles: string[]; } @@ -50,8 +50,8 @@ class PermissionsService { .take(limit); // Apply filters - if (filter?.module) { - queryBuilder.andWhere('permission.module = :module', { module: filter.module }); + if (filter?.category) { + queryBuilder.andWhere('permission.category = :category', { category: filter.category }); } if (filter?.resource) { queryBuilder.andWhere('permission.resource LIKE :resource', { @@ -96,23 +96,23 @@ class PermissionsService { /** * Get all unique modules */ - async getModules(): Promise { + async getCategories(): Promise { const result = await this.permissionRepository .createQueryBuilder('permission') - .select('DISTINCT permission.module', 'module') - .where('permission.module IS NOT NULL') - .orderBy('permission.module', 'ASC') + .select('DISTINCT permission.category', 'category') + .where('permission.category IS NOT NULL') + .orderBy('permission.category', 'ASC') .getRawMany(); - return result.map(r => r.module); + return result.map(r => r.category); } /** * Get all permissions for a specific module */ - async getByModule(module: string): Promise { + async getByCategory(category: string): Promise { return await this.permissionRepository.find({ - where: { module }, + where: { category }, order: { resource: 'ASC', action: 'ASC' }, }); } @@ -133,19 +133,19 @@ class PermissionsService { /** * Get permissions grouped by module */ - async getGroupedByModule(): Promise> { + async getGroupedByCategory(): Promise> { const permissions = await this.permissionRepository.find({ - order: { module: 'ASC', resource: 'ASC', action: 'ASC' }, + order: { category: 'ASC', resource: 'ASC', action: 'ASC' }, }); const grouped: Record = {}; for (const permission of permissions) { - const module = permission.module || 'other'; - if (!grouped[module]) { - grouped[module] = []; + const cat = permission.category || 'other'; + if (!grouped[cat]) { + grouped[cat] = []; } - grouped[module].push(permission); + grouped[cat].push(permission); } return grouped; @@ -188,7 +188,7 @@ class PermissionsService { permissionMap.set(key, { resource: permission.resource, action: permission.action, - module: permission.module, + category: permission.category, fromRoles: [role.name], }); } @@ -243,7 +243,7 @@ class PermissionsService { if (role.deletedAt) continue; // Super admin role has all permissions - if (role.code === 'super_admin') { + if (role.isSuperadmin) { return true; } @@ -299,7 +299,7 @@ class PermissionsService { async getPermissionMatrix( tenantId: string ): Promise<{ - roles: Array<{ id: string; name: string; code: string }>; + roles: Array<{ id: string; name: string }>; permissions: Permission[]; matrix: Record; }> { @@ -313,7 +313,7 @@ class PermissionsService { // Get all permissions const permissions = await this.permissionRepository.find({ - order: { module: 'ASC', resource: 'ASC', action: 'ASC' }, + order: { category: 'ASC', resource: 'ASC', action: 'ASC' }, }); // Build matrix: roleId -> [permissionIds] @@ -323,7 +323,7 @@ class PermissionsService { } 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, matrix, }; diff --git a/src/modules/roles/services/roles.service.ts b/src/modules/roles/services/roles.service.ts index 58ed2f8..57ad841 100644 --- a/src/modules/roles/services/roles.service.ts +++ b/src/modules/roles/services/roles.service.ts @@ -8,7 +8,6 @@ import { logger } from '../../../shared/utils/logger.js'; export interface CreateRoleDto { name: string; - code: string; description?: string; color?: 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 { + async findByName(tenantId: string, name: string): Promise { try { return await this.roleRepository.findOne({ where: { - code, + name, tenantId, deletedAt: undefined, }, relations: ['permissions'], }); } catch (error) { - logger.error('Error finding role by code', { + logger.error('Error finding role by name', { error: (error as Error).message, tenantId, - code, + name, }); throw error; } @@ -130,22 +129,16 @@ class RolesService { createdBy: string ): Promise { try { - // Validate code uniqueness within tenant - const existing = await this.findByCode(tenantId, data.code); + // Validate name uniqueness within tenant + const existing = await this.findByName(tenantId, data.name); if (existing) { - throw new ValidationError('Ya existe un rol con este código'); - } - - // Validate code format - if (!/^[a-z_]+$/.test(data.code)) { - throw new ValidationError('El código debe contener solo letras minúsculas y guiones bajos'); + throw new ValidationError('Ya existe un rol con este nombre'); } // Create role const role = this.roleRepository.create({ tenantId, name: data.name, - code: data.code, description: data.description || null, color: data.color || null, isSystem: false, @@ -165,7 +158,7 @@ class RolesService { logger.info('Role created', { roleId: role.id, tenantId, - code: role.code, + name: role.name, createdBy, }); @@ -251,7 +244,6 @@ class RolesService { // Soft delete role.deletedAt = new Date(); - role.deletedBy = deletedBy; await this.roleRepository.save(role); diff --git a/src/modules/storage/entities/file.entity.ts b/src/modules/storage/entities/file.entity.ts index 193b473..48cea54 100644 --- a/src/modules/storage/entities/file.entity.ts +++ b/src/modules/storage/entities/file.entity.ts @@ -82,13 +82,13 @@ export class StorageFile { @Column({ name: 'thumbnail_url', type: 'text', nullable: true }) thumbnailUrl: string; - @Column({ name: 'thumbnails', type: 'jsonb', default: {} }) + @Column({ name: 'thumbnails', type: 'jsonb', default: '{}' }) thumbnails: Record; - @Column({ name: 'metadata', type: 'jsonb', default: {} }) + @Column({ name: 'metadata', type: 'jsonb', default: '{}' }) metadata: Record; - @Column({ name: 'tags', type: 'text', array: true, default: [] }) + @Column({ name: 'tags', type: 'text', array: true, default: '{}' }) tags: string[]; @Column({ name: 'alt_text', type: 'text', nullable: true }) diff --git a/src/shared/middleware/auth.middleware.ts b/src/shared/middleware/auth.middleware.ts index 0b01de5..574b389 100644 --- a/src/shared/middleware/auth.middleware.ts +++ b/src/shared/middleware/auth.middleware.ts @@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken'; import { config } from '../../config/index.js'; import { AuthenticatedRequest, JwtPayload, UnauthorizedError, ForbiddenError } from '../types/index.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 { checkCachedPermission, getCachedPermissions, diff --git a/src/shared/middleware/rbac.middleware.ts b/src/shared/middleware/rbac.middleware.ts index 7f34d81..93c5843 100644 --- a/src/shared/middleware/rbac.middleware.ts +++ b/src/shared/middleware/rbac.middleware.ts @@ -1,7 +1,7 @@ import { Response, NextFunction } from 'express'; import { AuthenticatedRequest, UnauthorizedError, ForbiddenError } from '../types/index.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 { checkCachedPermission, getCachedPermissions, diff --git a/src/shared/services/permission-cache.service.ts b/src/shared/services/permission-cache.service.ts index f420340..088e4ca 100644 --- a/src/shared/services/permission-cache.service.ts +++ b/src/shared/services/permission-cache.service.ts @@ -1,6 +1,6 @@ import { redisClient, isRedisConnected } from '../../config/redis.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