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

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

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

View File

@ -29,8 +29,10 @@ import invoicesRoutes from './modules/invoices/invoices.routes.js';
import productsRoutes from './modules/products/products.routes.js';
import 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)

View File

@ -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,

View File

@ -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' })

View File

@ -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' })

View File

@ -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' })

View File

@ -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/

View File

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

View File

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

View File

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

View File

@ -2,190 +2,88 @@ import {
Entity,
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<string, any>;
// Timestamps
@Column({
type: 'jsonb',
nullable: false,
name: 'claim_mapping',
default: {
sub: 'oauth_uid',
email: 'email',
name: 'name',
picture: 'avatar_url',
},
})
claimMapping: Record<string, any>;
// UI
@Column({ type: 'varchar', length: 100, nullable: true, name: 'icon_class' })
iconClass: string | null;
@Column({ type: 'varchar', length: 100, nullable: true, name: 'button_text' })
buttonText: string | null;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'button_color' })
buttonColor: string | null;
@Column({
type: 'integer',
default: 10,
nullable: false,
name: 'display_order',
})
displayOrder: number;
// Estado
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_enabled' })
isEnabled: boolean;
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_visible' })
isVisible: boolean;
// Restricciones
@Column({
type: 'text',
array: true,
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;
}

View File

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

View File

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

View File

@ -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<string, any>;
// Timestamps
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
// Relations
@ManyToOne(() => Tenant, (tenant) => tenant.roles, {
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;
}

View File

@ -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<string, any>;
@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<string, any> | 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;
}

View File

@ -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' })

View File

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

View File

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

View File

@ -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' })

View File

@ -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' })

View File

@ -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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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' })

View File

@ -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' })

View File

@ -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' })

View File

@ -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;

View File

@ -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' })

View File

@ -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' })

View File

@ -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' })

View File

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

View File

@ -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;

View File

@ -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' })

View File

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

View File

@ -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' })

View File

@ -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' })

View File

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

View File

@ -111,7 +111,7 @@ export class Payment {
journalEntry: JournalEntry | null;
// 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' })

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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' })

View File

@ -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;

View File

@ -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' })

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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)

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -209,36 +209,21 @@ export class CreatePartnerAddressDto {
partnerId: string;
@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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -73,11 +73,11 @@ export class TimesheetEntity {
@Column({ type: 'uuid', nullable: true, name: 'approved_by' })
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;

View File

@ -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<void> {
async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
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<void> {
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<void> {
async getByCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
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,

View File

@ -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)

View File

@ -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<string[]> {
async getCategories(): Promise<string[]> {
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<Permission[]> {
async getByCategory(category: string): Promise<Permission[]> {
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<Record<string, Permission[]>> {
async getGroupedByCategory(): Promise<Record<string, Permission[]>> {
const permissions = await this.permissionRepository.find({
order: { module: 'ASC', resource: 'ASC', action: 'ASC' },
order: { category: 'ASC', resource: 'ASC', action: 'ASC' },
});
const grouped: Record<string, Permission[]> = {};
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<string, string[]>;
}> {
@ -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,
};

View File

@ -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<Role | null> {
async findByName(tenantId: string, name: string): Promise<Role | null> {
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<Role> {
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);

View File

@ -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<string, string>;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
@Column({ name: 'metadata', type: 'jsonb', default: '{}' })
metadata: Record<string, any>;
@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 })

View File

@ -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,

View File

@ -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,

View File

@ -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