[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:
parent
651450225e
commit
6a12ff0844
@ -29,8 +29,10 @@ import invoicesRoutes from './modules/invoices/invoices.routes.js';
|
|||||||
import productsRoutes from './modules/products/products.routes.js';
|
import productsRoutes from './modules/products/products.routes.js';
|
||||||
import warehousesRoutes from './modules/warehouses/warehouses.routes.js';
|
import warehousesRoutes from './modules/warehouses/warehouses.routes.js';
|
||||||
import fiscalRoutes from './modules/fiscal/fiscal.routes.js';
|
import fiscalRoutes from './modules/fiscal/fiscal.routes.js';
|
||||||
|
import cfdiRoutes from './modules/cfdi/cfdi.routes.js';
|
||||||
import auditRoutes from './modules/audit/audit.routes.js';
|
import auditRoutes from './modules/audit/audit.routes.js';
|
||||||
import featureFlagsRoutes from './modules/feature-flags/feature-flags.routes.js';
|
import featureFlagsRoutes from './modules/feature-flags/feature-flags.routes.js';
|
||||||
|
import mobileRoutes from './modules/mobile/mobile.routes.js';
|
||||||
import { featureFlagsMiddleware } from './modules/feature-flags/middleware/feature-flags.middleware.js';
|
import { featureFlagsMiddleware } from './modules/feature-flags/middleware/feature-flags.middleware.js';
|
||||||
|
|
||||||
const app: Application = express();
|
const app: Application = express();
|
||||||
@ -86,8 +88,10 @@ app.use(`${apiPrefix}/invoices`, invoicesRoutes);
|
|||||||
app.use(`${apiPrefix}/products`, productsRoutes);
|
app.use(`${apiPrefix}/products`, productsRoutes);
|
||||||
app.use(`${apiPrefix}/warehouses`, warehousesRoutes);
|
app.use(`${apiPrefix}/warehouses`, warehousesRoutes);
|
||||||
app.use(`${apiPrefix}/fiscal`, fiscalRoutes);
|
app.use(`${apiPrefix}/fiscal`, fiscalRoutes);
|
||||||
|
app.use(`${apiPrefix}/cfdi`, cfdiRoutes);
|
||||||
app.use(`${apiPrefix}/audit`, auditRoutes);
|
app.use(`${apiPrefix}/audit`, auditRoutes);
|
||||||
app.use(`${apiPrefix}/feature-flags`, featureFlagsRoutes);
|
app.use(`${apiPrefix}/feature-flags`, featureFlagsRoutes);
|
||||||
|
app.use(`${apiPrefix}/mobile`, mobileRoutes);
|
||||||
|
|
||||||
// Global helper middlewares (after auth logic if any global auth existed,
|
// Global helper middlewares (after auth logic if any global auth existed,
|
||||||
// but here routes handle auth, so we apply it to be available in all controllers)
|
// but here routes handle auth, so we apply it to be available in all controllers)
|
||||||
|
|||||||
@ -23,6 +23,10 @@ import {
|
|||||||
OAuthProvider,
|
OAuthProvider,
|
||||||
OAuthUserLink,
|
OAuthUserLink,
|
||||||
OAuthState,
|
OAuthState,
|
||||||
|
MfaDevice,
|
||||||
|
MfaBackupCode,
|
||||||
|
LoginAttempt,
|
||||||
|
TokenBlacklist,
|
||||||
} from '../modules/auth/entities/index.js';
|
} from '../modules/auth/entities/index.js';
|
||||||
|
|
||||||
// Import Core Module Entities
|
// Import Core Module Entities
|
||||||
@ -86,6 +90,15 @@ import {
|
|||||||
WithholdingType,
|
WithholdingType,
|
||||||
} from '../modules/fiscal/entities/index.js';
|
} from '../modules/fiscal/entities/index.js';
|
||||||
|
|
||||||
|
// Import CFDI Entities
|
||||||
|
import { CfdiInvoice } from '../modules/cfdi/entities/cfdi-invoice.entity.js';
|
||||||
|
import { CfdiCertificate } from '../modules/cfdi/entities/cfdi-certificate.entity.js';
|
||||||
|
import { CfdiCancellation } from '../modules/cfdi/entities/cfdi-cancellation.entity.js';
|
||||||
|
import { CfdiLog } from '../modules/cfdi/entities/cfdi-log.entity.js';
|
||||||
|
import { CfdiPaymentComplement } from '../modules/cfdi/entities/cfdi-payment-complement.entity.js';
|
||||||
|
import { CfdiPacConfiguration } from '../modules/cfdi/entities/cfdi-pac-configuration.entity.js';
|
||||||
|
import { CfdiStampQueue } from '../modules/cfdi/entities/cfdi-stamp-queue.entity.js';
|
||||||
|
|
||||||
// Import Settings Entities
|
// Import Settings Entities
|
||||||
import {
|
import {
|
||||||
SystemSetting,
|
SystemSetting,
|
||||||
@ -130,6 +143,10 @@ export const AppDataSource = new DataSource({
|
|||||||
OAuthProvider,
|
OAuthProvider,
|
||||||
OAuthUserLink,
|
OAuthUserLink,
|
||||||
OAuthState,
|
OAuthState,
|
||||||
|
MfaDevice,
|
||||||
|
MfaBackupCode,
|
||||||
|
LoginAttempt,
|
||||||
|
TokenBlacklist,
|
||||||
// Core Module Entities
|
// Core Module Entities
|
||||||
Partner,
|
Partner,
|
||||||
Currency,
|
Currency,
|
||||||
@ -179,6 +196,14 @@ export const AppDataSource = new DataSource({
|
|||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
PaymentType,
|
PaymentType,
|
||||||
WithholdingType,
|
WithholdingType,
|
||||||
|
// CFDI Entities
|
||||||
|
CfdiInvoice,
|
||||||
|
CfdiCertificate,
|
||||||
|
CfdiCancellation,
|
||||||
|
CfdiLog,
|
||||||
|
CfdiPaymentComplement,
|
||||||
|
CfdiPacConfiguration,
|
||||||
|
CfdiStampQueue,
|
||||||
// Settings Entities
|
// Settings Entities
|
||||||
SystemSetting,
|
SystemSetting,
|
||||||
PlanSetting,
|
PlanSetting,
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export class Company {
|
|||||||
users: User[];
|
users: User[];
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -77,7 +77,7 @@ export class Company {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@ -85,7 +85,7 @@ export class Company {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export class Device {
|
|||||||
@Column({ type: 'timestamptz', nullable: true, name: 'last_active_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'last_active_at' })
|
||||||
lastActiveAt: Date;
|
lastActiveAt: Date;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||||
|
|||||||
@ -69,19 +69,19 @@ export class Group {
|
|||||||
deletedByUser: User | null;
|
deletedByUser: User | null;
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
createdBy: string | null;
|
createdBy: string | null;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
|||||||
@ -20,6 +20,12 @@ export { ProfileModule } from './profile-module.entity.js';
|
|||||||
export { UserProfileAssignment } from './user-profile-assignment.entity.js';
|
export { UserProfileAssignment } from './user-profile-assignment.entity.js';
|
||||||
export { Device } from './device.entity.js';
|
export { Device } from './device.entity.js';
|
||||||
|
|
||||||
|
// Auth-extended entities (06-auth-extended.sql)
|
||||||
|
export { MfaDevice, MfaDeviceType } from './mfa-device.entity.js';
|
||||||
|
export { MfaBackupCode } from './mfa-backup-code.entity.js';
|
||||||
|
export { LoginAttempt } from './login-attempt.entity.js';
|
||||||
|
export { TokenBlacklist, TokenType } from './token-blacklist.entity.js';
|
||||||
|
|
||||||
// NOTE: The following entities are also available in their specific modules:
|
// NOTE: The following entities are also available in their specific modules:
|
||||||
// - UserProfile, ProfileTool, ProfileModule, UserProfileAssignment, Person -> profiles/entities/
|
// - UserProfile, ProfileTool, ProfileModule, UserProfileAssignment, Person -> profiles/entities/
|
||||||
// - Device, BiometricCredential, DeviceSession, DeviceActivityLog -> biometrics/entities/
|
// - Device, BiometricCredential, DeviceSession, DeviceActivityLog -> biometrics/entities/
|
||||||
|
|||||||
68
src/modules/auth/entities/login-attempt.entity.ts
Normal file
68
src/modules/auth/entities/login-attempt.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
43
src/modules/auth/entities/mfa-backup-code.entity.ts
Normal file
43
src/modules/auth/entities/mfa-backup-code.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
75
src/modules/auth/entities/mfa-device.entity.ts
Normal file
75
src/modules/auth/entities/mfa-device.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -2,190 +2,88 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
Index,
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Tenant } from './tenant.entity.js';
|
|
||||||
import { User } from './user.entity.js';
|
import { User } from './user.entity.js';
|
||||||
import { Role } from './role.entity.js';
|
import { Tenant } from './tenant.entity.js';
|
||||||
|
|
||||||
@Entity({ schema: 'auth', name: 'oauth_providers' })
|
@Entity({ schema: 'auth', name: 'oauth_providers' })
|
||||||
@Index('idx_oauth_providers_enabled', ['isEnabled'])
|
@Unique(['provider', 'providerUserId'])
|
||||||
@Index('idx_oauth_providers_tenant', ['tenantId'])
|
@Index('idx_oauth_providers_user', ['userId'])
|
||||||
@Index('idx_oauth_providers_code', ['code'])
|
@Index('idx_oauth_providers_provider', ['provider', 'providerUserId'])
|
||||||
|
@Index('idx_oauth_providers_email', ['providerEmail'])
|
||||||
export class OAuthProvider {
|
export class OAuthProvider {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'tenant_id' })
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
tenantId: string | null;
|
userId: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 50, nullable: false, unique: true })
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
code: string;
|
tenantId: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 100, nullable: false })
|
// Provider info
|
||||||
name: string;
|
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||||
|
provider: string;
|
||||||
// Configuración OAuth2
|
|
||||||
@Column({ type: 'varchar', length: 255, nullable: false, name: 'client_id' })
|
|
||||||
clientId: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 500, nullable: true, name: 'client_secret' })
|
|
||||||
clientSecret: string | null;
|
|
||||||
|
|
||||||
// Endpoints OAuth2
|
|
||||||
@Column({
|
|
||||||
type: 'varchar',
|
|
||||||
length: 500,
|
|
||||||
nullable: false,
|
|
||||||
name: 'authorization_endpoint',
|
|
||||||
})
|
|
||||||
authorizationEndpoint: string;
|
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
length: 500,
|
length: 255,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
name: 'token_endpoint',
|
name: 'provider_user_id',
|
||||||
})
|
})
|
||||||
tokenEndpoint: string;
|
providerUserId: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
length: 500,
|
length: 255,
|
||||||
nullable: false,
|
|
||||||
name: 'userinfo_endpoint',
|
|
||||||
})
|
|
||||||
userinfoEndpoint: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 500, nullable: true, name: 'jwks_uri' })
|
|
||||||
jwksUri: string | null;
|
|
||||||
|
|
||||||
// Scopes y parámetros
|
|
||||||
@Column({
|
|
||||||
type: 'varchar',
|
|
||||||
length: 500,
|
|
||||||
default: 'openid profile email',
|
|
||||||
nullable: false,
|
|
||||||
})
|
|
||||||
scope: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'varchar',
|
|
||||||
length: 50,
|
|
||||||
default: 'code',
|
|
||||||
nullable: false,
|
|
||||||
name: 'response_type',
|
|
||||||
})
|
|
||||||
responseType: string;
|
|
||||||
|
|
||||||
// PKCE Configuration
|
|
||||||
@Column({
|
|
||||||
type: 'boolean',
|
|
||||||
default: true,
|
|
||||||
nullable: false,
|
|
||||||
name: 'pkce_enabled',
|
|
||||||
})
|
|
||||||
pkceEnabled: boolean;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'varchar',
|
|
||||||
length: 10,
|
|
||||||
default: 'S256',
|
|
||||||
nullable: true,
|
nullable: true,
|
||||||
name: 'code_challenge_method',
|
name: 'provider_email',
|
||||||
})
|
})
|
||||||
codeChallengeMethod: string | null;
|
providerEmail: string | null;
|
||||||
|
|
||||||
// Mapeo de claims
|
// Tokens (encrypted)
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'access_token_encrypted' })
|
||||||
|
accessTokenEncrypted: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'refresh_token_encrypted' })
|
||||||
|
refreshTokenEncrypted: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'token_expires_at' })
|
||||||
|
tokenExpiresAt: Date | null;
|
||||||
|
|
||||||
|
// Profile data from provider
|
||||||
|
@Column({ type: 'jsonb', default: '{}', name: 'profile_data' })
|
||||||
|
profileData: Record<string, any>;
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
@Column({
|
@Column({
|
||||||
type: 'jsonb',
|
type: 'timestamptz',
|
||||||
nullable: false,
|
|
||||||
name: 'claim_mapping',
|
|
||||||
default: {
|
|
||||||
sub: 'oauth_uid',
|
|
||||||
email: 'email',
|
|
||||||
name: 'name',
|
|
||||||
picture: 'avatar_url',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
claimMapping: Record<string, any>;
|
|
||||||
|
|
||||||
// UI
|
|
||||||
@Column({ type: 'varchar', length: 100, nullable: true, name: 'icon_class' })
|
|
||||||
iconClass: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 100, nullable: true, name: 'button_text' })
|
|
||||||
buttonText: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 20, nullable: true, name: 'button_color' })
|
|
||||||
buttonColor: string | null;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'integer',
|
|
||||||
default: 10,
|
|
||||||
nullable: false,
|
|
||||||
name: 'display_order',
|
|
||||||
})
|
|
||||||
displayOrder: number;
|
|
||||||
|
|
||||||
// Estado
|
|
||||||
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_enabled' })
|
|
||||||
isEnabled: boolean;
|
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_visible' })
|
|
||||||
isVisible: boolean;
|
|
||||||
|
|
||||||
// Restricciones
|
|
||||||
@Column({
|
|
||||||
type: 'text',
|
|
||||||
array: true,
|
|
||||||
nullable: true,
|
nullable: true,
|
||||||
name: 'allowed_domains',
|
name: 'linked_at',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
})
|
})
|
||||||
allowedDomains: string[] | null;
|
linkedAt: Date | null;
|
||||||
|
|
||||||
@Column({
|
@Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' })
|
||||||
type: 'boolean',
|
lastUsedAt: Date | null;
|
||||||
default: false,
|
|
||||||
nullable: false,
|
@Column({ type: 'timestamptz', nullable: true, name: 'unlinked_at' })
|
||||||
name: 'auto_create_users',
|
unlinkedAt: Date | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => User, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
autoCreateUsers: boolean;
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'default_role_id' })
|
@ManyToOne(() => Tenant, {
|
||||||
defaultRoleId: string | null;
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
// Relaciones
|
|
||||||
@ManyToOne(() => Tenant, { onDelete: 'CASCADE', nullable: true })
|
|
||||||
@JoinColumn({ name: 'tenant_id' })
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
tenant: Tenant | null;
|
tenant: Tenant;
|
||||||
|
|
||||||
@ManyToOne(() => Role, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'default_role_id' })
|
|
||||||
defaultRole: Role | null;
|
|
||||||
|
|
||||||
@ManyToOne(() => User, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'created_by' })
|
|
||||||
createdByUser: User | null;
|
|
||||||
|
|
||||||
@ManyToOne(() => User, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'updated_by' })
|
|
||||||
updatedByUser: User | null;
|
|
||||||
|
|
||||||
// Auditoría
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
|
||||||
createdAt: Date;
|
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
|
||||||
createdBy: string | null;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
|
||||||
updatedAt: Date;
|
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
|
||||||
updatedBy: string | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,10 +23,10 @@ export class PasswordReset {
|
|||||||
@Column({ type: 'varchar', length: 500, unique: true, nullable: false })
|
@Column({ type: 'varchar', length: 500, unique: true, nullable: false })
|
||||||
token: string;
|
token: string;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: false, name: 'expires_at' })
|
@Column({ type: 'timestamptz', nullable: false, name: 'expires_at' })
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'used_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'used_at' })
|
||||||
usedAt: Date | null;
|
usedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
||||||
@ -40,6 +40,6 @@ export class PasswordReset {
|
|||||||
user: User;
|
user: User;
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,48 +5,67 @@ import {
|
|||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Index,
|
Index,
|
||||||
ManyToMany,
|
ManyToMany,
|
||||||
|
Unique,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Role } from './role.entity.js';
|
import { Role } from './role.entity.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum for permission actions.
|
||||||
|
* DDL uses VARCHAR(50) -- not a PostgreSQL enum -- but we keep this TS enum
|
||||||
|
* for type safety. Values must match the seed data in 07-users-rbac.sql.
|
||||||
|
*/
|
||||||
export enum PermissionAction {
|
export enum PermissionAction {
|
||||||
CREATE = 'create',
|
CREATE = 'create',
|
||||||
READ = 'read',
|
READ = 'read',
|
||||||
UPDATE = 'update',
|
UPDATE = 'update',
|
||||||
DELETE = 'delete',
|
DELETE = 'delete',
|
||||||
APPROVE = 'approve',
|
|
||||||
CANCEL = 'cancel',
|
|
||||||
EXPORT = 'export',
|
EXPORT = 'export',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: 'auth', name: 'permissions' })
|
/**
|
||||||
|
* Entity: users.permissions
|
||||||
|
* DDL source: database/ddl/07-users-rbac.sql
|
||||||
|
*
|
||||||
|
* Permisos granulares del sistema (resource.action.scope).
|
||||||
|
* Global -- no tenant_id column.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'users', name: 'permissions' })
|
||||||
|
@Unique(['resource', 'action', 'scope'])
|
||||||
@Index('idx_permissions_resource', ['resource'])
|
@Index('idx_permissions_resource', ['resource'])
|
||||||
@Index('idx_permissions_action', ['action'])
|
@Index('idx_permissions_category', ['category'])
|
||||||
@Index('idx_permissions_module', ['module'])
|
|
||||||
export class Permission {
|
export class Permission {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
@Column({ type: 'varchar', length: 100, nullable: false })
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
resource: string;
|
resource: string;
|
||||||
|
|
||||||
@Column({
|
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||||
type: 'enum',
|
action: string;
|
||||||
enum: PermissionAction,
|
|
||||||
nullable: false,
|
@Column({ type: 'varchar', length: 50, default: "'own'", nullable: true })
|
||||||
})
|
scope: string;
|
||||||
action: PermissionAction;
|
|
||||||
|
// Info
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true, name: 'display_name' })
|
||||||
|
displayName: string | null;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
module: string | null;
|
category: string | null;
|
||||||
|
|
||||||
// Relaciones
|
// Flags
|
||||||
|
@Column({ type: 'boolean', default: false, name: 'is_dangerous' })
|
||||||
|
isDangerous: boolean;
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
@ManyToMany(() => Role, (role) => role.permissions)
|
@ManyToMany(() => Role, (role) => role.permissions)
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
|
|
||||||
// Sin tenant_id: permisos son globales
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,48 +9,111 @@ import {
|
|||||||
ManyToMany,
|
ManyToMany,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
JoinTable,
|
JoinTable,
|
||||||
|
OneToMany,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Tenant } from './tenant.entity.js';
|
import { Tenant } from './tenant.entity.js';
|
||||||
import { User } from './user.entity.js';
|
import { User } from './user.entity.js';
|
||||||
import { Permission } from './permission.entity.js';
|
import { Permission } from './permission.entity.js';
|
||||||
|
|
||||||
@Entity({ schema: 'auth', name: 'roles' })
|
/**
|
||||||
@Index('idx_roles_tenant_id', ['tenantId'])
|
* Entity: users.roles
|
||||||
@Index('idx_roles_code', ['code'])
|
* DDL source: database/ddl/07-users-rbac.sql
|
||||||
@Index('idx_roles_is_system', ['isSystem'])
|
*
|
||||||
|
* Roles del sistema con herencia. Supports hierarchy via parent_role_id,
|
||||||
|
* system flags, and soft-delete via deleted_at.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'users', name: 'roles' })
|
||||||
|
@Index('idx_roles_tenant', ['tenantId'])
|
||||||
|
@Index('idx_roles_parent', ['parentRoleId'])
|
||||||
|
@Index('idx_roles_system', ['isSystem'])
|
||||||
|
@Index('idx_roles_default', ['tenantId', 'isDefault'])
|
||||||
export class Role {
|
export class Role {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
@Column({ type: 'uuid', nullable: true, name: 'tenant_id' })
|
||||||
tenantId: string;
|
tenantId: string | null;
|
||||||
|
|
||||||
|
// Info basica
|
||||||
@Column({ type: 'varchar', length: 100, nullable: false })
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 50, nullable: false })
|
@Column({ type: 'varchar', length: 255, nullable: true, name: 'display_name' })
|
||||||
code: string;
|
displayName: string | null;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' })
|
|
||||||
isSystem: boolean;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
|
||||||
// Relaciones
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
icon: string | null;
|
||||||
|
|
||||||
|
// Jerarquia
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'parent_role_id' })
|
||||||
|
parentRoleId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0, name: 'hierarchy_level' })
|
||||||
|
hierarchyLevel: number;
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
@Column({ type: 'boolean', default: false, name: 'is_system' })
|
||||||
|
isSystem: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false, name: 'is_default' })
|
||||||
|
isDefault: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false, name: 'is_superadmin' })
|
||||||
|
isSuperadmin: boolean;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'jsonb', default: '{}' })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
@ManyToOne(() => Tenant, (tenant) => tenant.roles, {
|
@ManyToOne(() => Tenant, (tenant) => tenant.roles, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
|
nullable: true,
|
||||||
})
|
})
|
||||||
@JoinColumn({ name: 'tenant_id' })
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
tenant: Tenant;
|
tenant: Tenant | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Role, { onDelete: 'SET NULL', nullable: true })
|
||||||
|
@JoinColumn({ name: 'parent_role_id' })
|
||||||
|
parentRole: Role | null;
|
||||||
|
|
||||||
|
@OneToMany(() => Role, (role) => role.parentRole)
|
||||||
|
childRoles: Role[];
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'created_by' })
|
||||||
|
creator: User | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'updated_by' })
|
||||||
|
updater: User | null;
|
||||||
|
|
||||||
@ManyToMany(() => Permission, (permission) => permission.roles)
|
@ManyToMany(() => Permission, (permission) => permission.roles)
|
||||||
@JoinTable({
|
@JoinTable({
|
||||||
name: 'role_permissions',
|
name: 'role_permissions',
|
||||||
schema: 'auth',
|
schema: 'users',
|
||||||
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
||||||
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
|
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
|
||||||
})
|
})
|
||||||
@ -58,27 +121,4 @@ export class Role {
|
|||||||
|
|
||||||
@ManyToMany(() => User, (user) => user.roles)
|
@ManyToMany(() => User, (user) => user.roles)
|
||||||
users: User[];
|
users: User[];
|
||||||
|
|
||||||
// Auditoría
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
|
||||||
createdAt: Date;
|
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
|
||||||
createdBy: string | null;
|
|
||||||
|
|
||||||
@UpdateDateColumn({
|
|
||||||
name: 'updated_at',
|
|
||||||
type: 'timestamp',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
updatedAt: Date | null;
|
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
|
||||||
updatedBy: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
|
||||||
deletedAt: Date | null;
|
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
|
||||||
deletedBy: string | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,18 +8,25 @@ import {
|
|||||||
JoinColumn,
|
JoinColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { User } from './user.entity.js';
|
import { User } from './user.entity.js';
|
||||||
|
import { Tenant } from './tenant.entity.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logical session status derived from DDL fields (revoked_at, expires_at).
|
||||||
|
* DDL does not have a 'status' column -- this enum is used at the application
|
||||||
|
* layer by token.service.ts for convenience. It is NOT persisted directly.
|
||||||
|
*/
|
||||||
export enum SessionStatus {
|
export enum SessionStatus {
|
||||||
ACTIVE = 'active',
|
ACTIVE = 'active',
|
||||||
EXPIRED = 'expired',
|
|
||||||
REVOKED = 'revoked',
|
REVOKED = 'revoked',
|
||||||
|
EXPIRED = 'expired',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: 'auth', name: 'sessions' })
|
@Entity({ schema: 'auth', name: 'sessions' })
|
||||||
@Index('idx_sessions_user_id', ['userId'])
|
@Index('idx_sessions_user', ['userId'])
|
||||||
@Index('idx_sessions_token', ['token'])
|
@Index('idx_sessions_tenant', ['tenantId'])
|
||||||
@Index('idx_sessions_status', ['status'])
|
@Index('idx_sessions_jti', ['jti'])
|
||||||
@Index('idx_sessions_expires_at', ['expiresAt'])
|
@Index('idx_sessions_expires', ['expiresAt'])
|
||||||
|
@Index('idx_sessions_active', ['userId', 'revokedAt'])
|
||||||
export class Session {
|
export class Session {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
@ -27,57 +34,64 @@ export class Session {
|
|||||||
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 500, unique: true, nullable: false })
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
token: string;
|
tenantId: string;
|
||||||
|
|
||||||
|
// Token info
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 255,
|
||||||
|
nullable: false,
|
||||||
|
name: 'refresh_token_hash',
|
||||||
|
})
|
||||||
|
refreshTokenHash: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
length: 500,
|
length: 255,
|
||||||
unique: true,
|
unique: true,
|
||||||
nullable: true,
|
|
||||||
name: 'refresh_token',
|
|
||||||
})
|
|
||||||
refreshToken: string | null;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: SessionStatus,
|
|
||||||
default: SessionStatus.ACTIVE,
|
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
status: SessionStatus;
|
jti: string;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: false, name: 'expires_at' })
|
// Device info
|
||||||
expiresAt: Date;
|
@Column({ type: 'jsonb', default: '{}', name: 'device_info' })
|
||||||
|
deviceInfo: Record<string, any>;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'timestamp',
|
type: 'varchar',
|
||||||
|
length: 255,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
name: 'refresh_expires_at',
|
name: 'device_fingerprint',
|
||||||
})
|
})
|
||||||
refreshExpiresAt: Date | null;
|
deviceFingerprint: string | null;
|
||||||
|
|
||||||
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
|
||||||
ipAddress: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true, name: 'user_agent' })
|
@Column({ type: 'text', nullable: true, name: 'user_agent' })
|
||||||
userAgent: string | null;
|
userAgent: string | null;
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true, name: 'device_info' })
|
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
||||||
deviceInfo: Record<string, any> | null;
|
ipAddress: string | null;
|
||||||
|
|
||||||
// Relaciones
|
// Geo info
|
||||||
@ManyToOne(() => User, (user) => user.sessions, {
|
@Column({ type: 'varchar', length: 2, nullable: true, name: 'country_code' })
|
||||||
onDelete: 'CASCADE',
|
countryCode: string | null;
|
||||||
})
|
|
||||||
@JoinColumn({ name: 'user_id' })
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
user: User;
|
city: string | null;
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@Column({
|
||||||
createdAt: Date;
|
type: 'timestamptz',
|
||||||
|
nullable: true,
|
||||||
|
name: 'last_activity_at',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
|
lastActivityAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'revoked_at' })
|
@Column({ type: 'timestamptz', nullable: false, name: 'expires_at' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' })
|
||||||
revokedAt: Date | null;
|
revokedAt: Date | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
@ -87,4 +101,20 @@ export class Session {
|
|||||||
name: 'revoked_reason',
|
name: 'revoked_reason',
|
||||||
})
|
})
|
||||||
revokedReason: string | null;
|
revokedReason: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => User, (user) => user.sessions, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tenant, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export class Tenant {
|
|||||||
roles: Role[];
|
roles: Role[];
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -77,7 +77,7 @@ export class Tenant {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@ -85,7 +85,7 @@ export class Tenant {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
|||||||
66
src/modules/auth/entities/token-blacklist.entity.ts
Normal file
66
src/modules/auth/entities/token-blacklist.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -23,7 +23,7 @@ export class UserProfileAssignment {
|
|||||||
@Column({ name: 'is_default', default: false })
|
@Column({ name: 'is_default', default: false })
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'assigned_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'assigned_at', type: 'timestamptz' })
|
||||||
assignedAt: Date;
|
assignedAt: Date;
|
||||||
|
|
||||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
|||||||
@ -34,10 +34,10 @@ export class UserProfile {
|
|||||||
@Column({ name: 'is_active', default: true })
|
@Column({ name: 'is_active', default: true })
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||||
|
|||||||
@ -79,13 +79,13 @@ export class User {
|
|||||||
oauthProviderId: string;
|
oauthProviderId: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
name: 'email_verified_at',
|
name: 'email_verified_at',
|
||||||
})
|
})
|
||||||
emailVerifiedAt: Date | null;
|
emailVerifiedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'last_login_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'last_login_at' })
|
||||||
lastLoginAt: Date | null;
|
lastLoginAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'inet', nullable: true, name: 'last_login_ip' })
|
@Column({ type: 'inet', nullable: true, name: 'last_login_ip' })
|
||||||
@ -113,7 +113,7 @@ export class User {
|
|||||||
@ManyToMany(() => Role, (role) => role.users)
|
@ManyToMany(() => Role, (role) => role.users)
|
||||||
@JoinTable({
|
@JoinTable({
|
||||||
name: 'user_roles',
|
name: 'user_roles',
|
||||||
schema: 'auth',
|
schema: 'users',
|
||||||
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
|
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
|
||||||
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
||||||
})
|
})
|
||||||
@ -135,7 +135,7 @@ export class User {
|
|||||||
passwordResets: PasswordReset[];
|
passwordResets: PasswordReset[];
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -143,7 +143,7 @@ export class User {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@ -151,7 +151,7 @@ export class User {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, Not, Repository } from 'typeorm';
|
||||||
import { AppDataSource } from '../../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { config } from '../../../config/index.js';
|
import { config } from '../../../config/index.js';
|
||||||
import { User, Session, SessionStatus } from '../entities/index.js';
|
import { User, Session, SessionStatus } from '../entities/index.js';
|
||||||
@ -71,7 +71,7 @@ class TokenService {
|
|||||||
logger.debug('Generating token pair', { userId: user.id, tenantId: user.tenantId });
|
logger.debug('Generating token pair', { userId: user.id, tenantId: user.tenantId });
|
||||||
|
|
||||||
// Extract role codes from user roles
|
// Extract role codes from user roles
|
||||||
const roles = user.roles ? user.roles.map(role => role.code) : [];
|
const roles = user.roles ? user.roles.map(role => role.name) : [];
|
||||||
|
|
||||||
// Calculate expiration dates
|
// Calculate expiration dates
|
||||||
const accessTokenExpiresAt = this.calculateExpiration(this.ACCESS_TOKEN_EXPIRY);
|
const accessTokenExpiresAt = this.calculateExpiration(this.ACCESS_TOKEN_EXPIRY);
|
||||||
@ -102,11 +102,10 @@ class TokenService {
|
|||||||
// Create session record in database
|
// Create session record in database
|
||||||
const session = this.sessionRepository.create({
|
const session = this.sessionRepository.create({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
token: accessJti, // Store JTI instead of full token
|
tenantId: user.tenantId,
|
||||||
refreshToken: refreshJti, // Store JTI instead of full token
|
jti: accessJti,
|
||||||
status: SessionStatus.ACTIVE,
|
refreshTokenHash: refreshJti,
|
||||||
expiresAt: accessTokenExpiresAt,
|
expiresAt: refreshTokenExpiresAt,
|
||||||
refreshExpiresAt: refreshTokenExpiresAt,
|
|
||||||
ipAddress: metadata.ipAddress,
|
ipAddress: metadata.ipAddress,
|
||||||
userAgent: metadata.userAgent,
|
userAgent: metadata.userAgent,
|
||||||
});
|
});
|
||||||
@ -153,8 +152,8 @@ class TokenService {
|
|||||||
// Find active session with this refresh token JTI
|
// Find active session with this refresh token JTI
|
||||||
const session = await this.sessionRepository.findOne({
|
const session = await this.sessionRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
refreshToken: payload.jti,
|
refreshTokenHash: payload.jti,
|
||||||
status: SessionStatus.ACTIVE,
|
revokedAt: IsNull(),
|
||||||
},
|
},
|
||||||
relations: ['user', 'user.roles'],
|
relations: ['user', 'user.roles'],
|
||||||
});
|
});
|
||||||
@ -189,10 +188,10 @@ class TokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify session hasn't expired
|
// Verify session hasn't expired
|
||||||
if (session.refreshExpiresAt && new Date() > session.refreshExpiresAt) {
|
if (session.expiresAt && new Date() > session.expiresAt) {
|
||||||
logger.warn('Refresh token expired', {
|
logger.warn('Refresh token expired', {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
expiredAt: session.refreshExpiresAt,
|
expiredAt: session.expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.revokeSession(session.id, 'Token expired');
|
await this.revokeSession(session.id, 'Token expired');
|
||||||
@ -200,7 +199,6 @@ class TokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark current session as used (revoke it)
|
// Mark current session as used (revoke it)
|
||||||
session.status = SessionStatus.REVOKED;
|
|
||||||
session.revokedAt = new Date();
|
session.revokedAt = new Date();
|
||||||
session.revokedReason = 'Used for refresh';
|
session.revokedReason = 'Used for refresh';
|
||||||
await this.sessionRepository.save(session);
|
await this.sessionRepository.save(session);
|
||||||
@ -242,7 +240,6 @@ class TokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark session as revoked
|
// Mark session as revoked
|
||||||
session.status = SessionStatus.REVOKED;
|
|
||||||
session.revokedAt = new Date();
|
session.revokedAt = new Date();
|
||||||
session.revokedReason = reason;
|
session.revokedReason = reason;
|
||||||
await this.sessionRepository.save(session);
|
await this.sessionRepository.save(session);
|
||||||
@ -250,7 +247,7 @@ class TokenService {
|
|||||||
// Blacklist the access token (JTI) in Redis
|
// Blacklist the access token (JTI) in Redis
|
||||||
const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
|
const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
|
||||||
if (remainingTTL > 0) {
|
if (remainingTTL > 0) {
|
||||||
await this.blacklistAccessToken(session.token, remainingTTL);
|
await this.blacklistAccessToken(session.jti, remainingTTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Session revoked successfully', { sessionId, reason });
|
logger.info('Session revoked successfully', { sessionId, reason });
|
||||||
@ -277,7 +274,7 @@ class TokenService {
|
|||||||
const sessions = await this.sessionRepository.find({
|
const sessions = await this.sessionRepository.find({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
status: SessionStatus.ACTIVE,
|
revokedAt: IsNull(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -288,14 +285,13 @@ class TokenService {
|
|||||||
|
|
||||||
// Revoke each session
|
// Revoke each session
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
session.status = SessionStatus.REVOKED;
|
|
||||||
session.revokedAt = new Date();
|
session.revokedAt = new Date();
|
||||||
session.revokedReason = reason;
|
session.revokedReason = reason;
|
||||||
|
|
||||||
// Blacklist access token
|
// Blacklist access token
|
||||||
const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
|
const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
|
||||||
if (remainingTTL > 0) {
|
if (remainingTTL > 0) {
|
||||||
await this.blacklistAccessToken(session.token, remainingTTL);
|
await this.blacklistAccessToken(session.jti, remainingTTL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
686
src/modules/cfdi/cfdi.controller.ts
Normal file
686
src/modules/cfdi/cfdi.controller.ts
Normal 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();
|
||||||
59
src/modules/cfdi/cfdi.module.ts
Normal file
59
src/modules/cfdi/cfdi.module.ts
Normal 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';
|
||||||
82
src/modules/cfdi/cfdi.routes.ts
Normal file
82
src/modules/cfdi/cfdi.routes.ts
Normal 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;
|
||||||
158
src/modules/cfdi/dto/cancel-invoice.dto.ts
Normal file
158
src/modules/cfdi/dto/cancel-invoice.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
96
src/modules/cfdi/dto/create-certificate.dto.ts
Normal file
96
src/modules/cfdi/dto/create-certificate.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
3
src/modules/cfdi/dto/index.ts
Normal file
3
src/modules/cfdi/dto/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './create-certificate.dto.js';
|
||||||
|
export * from './stamp-invoice.dto.js';
|
||||||
|
export * from './cancel-invoice.dto.js';
|
||||||
303
src/modules/cfdi/dto/stamp-invoice.dto.ts
Normal file
303
src/modules/cfdi/dto/stamp-invoice.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
141
src/modules/cfdi/entities/cfdi-cancellation.entity.ts
Normal file
141
src/modules/cfdi/entities/cfdi-cancellation.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
100
src/modules/cfdi/entities/cfdi-certificate.entity.ts
Normal file
100
src/modules/cfdi/entities/cfdi-certificate.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
259
src/modules/cfdi/entities/cfdi-invoice.entity.ts
Normal file
259
src/modules/cfdi/entities/cfdi-invoice.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
109
src/modules/cfdi/entities/cfdi-log.entity.ts
Normal file
109
src/modules/cfdi/entities/cfdi-log.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
98
src/modules/cfdi/entities/cfdi-pac-configuration.entity.ts
Normal file
98
src/modules/cfdi/entities/cfdi-pac-configuration.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
183
src/modules/cfdi/entities/cfdi-payment-complement.entity.ts
Normal file
183
src/modules/cfdi/entities/cfdi-payment-complement.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
82
src/modules/cfdi/entities/cfdi-stamp-queue.entity.ts
Normal file
82
src/modules/cfdi/entities/cfdi-stamp-queue.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
7
src/modules/cfdi/entities/index.ts
Normal file
7
src/modules/cfdi/entities/index.ts
Normal 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';
|
||||||
28
src/modules/cfdi/enums/cancellation-reason.enum.ts
Normal file
28
src/modules/cfdi/enums/cancellation-reason.enum.ts
Normal 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',
|
||||||
|
};
|
||||||
30
src/modules/cfdi/enums/cfdi-status.enum.ts
Normal file
30
src/modules/cfdi/enums/cfdi-status.enum.ts
Normal 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',
|
||||||
|
}
|
||||||
72
src/modules/cfdi/enums/cfdi-type.enum.ts
Normal file
72
src/modules/cfdi/enums/cfdi-type.enum.ts
Normal 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',
|
||||||
|
}
|
||||||
3
src/modules/cfdi/enums/index.ts
Normal file
3
src/modules/cfdi/enums/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './cfdi-status.enum.js';
|
||||||
|
export * from './cancellation-reason.enum.js';
|
||||||
|
export * from './cfdi-type.enum.js';
|
||||||
188
src/modules/cfdi/interfaces/finkok-response.interface.ts
Normal file
188
src/modules/cfdi/interfaces/finkok-response.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
1
src/modules/cfdi/interfaces/index.ts
Normal file
1
src/modules/cfdi/interfaces/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './finkok-response.interface.js';
|
||||||
827
src/modules/cfdi/services/cfdi.service.ts
Normal file
827
src/modules/cfdi/services/cfdi.service.ts
Normal 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();
|
||||||
10
src/modules/cfdi/services/index.ts
Normal file
10
src/modules/cfdi/services/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
cfdiService,
|
||||||
|
CreateCfdiInvoiceDto,
|
||||||
|
UpdateCfdiInvoiceDto,
|
||||||
|
CfdiFilters,
|
||||||
|
CertificateFilters,
|
||||||
|
UploadCertificateDto,
|
||||||
|
CancellationRequestDto,
|
||||||
|
CfdiStatusResponse,
|
||||||
|
} from './cfdi.service.js';
|
||||||
@ -30,6 +30,6 @@ export class Country {
|
|||||||
currencyCode: string | null;
|
currencyCode: string | null;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,6 @@ export class Currency {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -121,10 +121,10 @@ export class DiscountRule {
|
|||||||
})
|
})
|
||||||
conditionValue: number | null;
|
conditionValue: number | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'start_date' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'start_date' })
|
||||||
startDate: Date | null;
|
startDate: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'end_date' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'end_date' })
|
||||||
endDate: Date | null;
|
endDate: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'integer', nullable: false, default: 10 })
|
@Column({ type: 'integer', nullable: false, default: 10 })
|
||||||
@ -143,13 +143,13 @@ export class DiscountRule {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
createdBy: string | null;
|
createdBy: string | null;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
|||||||
@ -124,13 +124,13 @@ export class PaymentTerm {
|
|||||||
lines: PaymentTermLine[];
|
lines: PaymentTermLine[];
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
createdBy: string | null;
|
createdBy: string | null;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export class ProductCategory {
|
|||||||
children: ProductCategory[];
|
children: ProductCategory[];
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -63,7 +63,7 @@ export class ProductCategory {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@ -71,7 +71,7 @@ export class ProductCategory {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export class Sequence {
|
|||||||
resetPeriod: ResetPeriod | null;
|
resetPeriod: ResetPeriod | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
name: 'last_reset_date',
|
name: 'last_reset_date',
|
||||||
})
|
})
|
||||||
@ -65,7 +65,7 @@ export class Sequence {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -73,7 +73,7 @@ export class Sequence {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export class Account {
|
|||||||
children: Account[];
|
children: Account[];
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -77,7 +77,7 @@ export class Account {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@ -85,7 +85,7 @@ export class Account {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export class FiscalPeriod {
|
|||||||
})
|
})
|
||||||
status: FiscalPeriodStatus;
|
status: FiscalPeriodStatus;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'closed_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'closed_at' })
|
||||||
closedAt: Date | null;
|
closedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'closed_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'closed_by' })
|
||||||
@ -56,7 +56,7 @@ export class FiscalPeriod {
|
|||||||
fiscalYear: FiscalYear;
|
fiscalYear: FiscalYear;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export class FiscalYear {
|
|||||||
periods: FiscalPeriod[];
|
periods: FiscalPeriod[];
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export { InvoiceLine } from './invoice-line.entity.js';
|
|||||||
|
|
||||||
// Payment entities
|
// Payment entities
|
||||||
export { Payment, PaymentType, PaymentMethod, PaymentStatus } from './payment.entity.js';
|
export { Payment, PaymentType, PaymentMethod, PaymentStatus } from './payment.entity.js';
|
||||||
|
export { PaymentInvoiceAllocation } from './payment-invoice-allocation.entity.js';
|
||||||
|
|
||||||
// Tax entities
|
// Tax entities
|
||||||
export { Tax, TaxType } from './tax.entity.js';
|
export { Tax, TaxType } from './tax.entity.js';
|
||||||
|
|||||||
@ -67,12 +67,12 @@ export class InvoiceLine {
|
|||||||
account: Account | null;
|
account: Account | null;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@ -122,7 +122,7 @@ export class Invoice {
|
|||||||
lines: InvoiceLine[];
|
lines: InvoiceLine[];
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -130,7 +130,7 @@ export class Invoice {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@ -138,13 +138,13 @@ export class Invoice {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'validated_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'validated_at' })
|
||||||
validatedAt: Date | null;
|
validatedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'validated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'validated_by' })
|
||||||
validatedBy: string | null;
|
validatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'cancelled_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'cancelled_at' })
|
||||||
cancelledAt: Date | null;
|
cancelledAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'cancelled_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'cancelled_by' })
|
||||||
|
|||||||
@ -54,6 +54,6 @@ export class JournalEntryLine {
|
|||||||
account: Account;
|
account: Account;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export class JournalEntry {
|
|||||||
lines: JournalEntryLine[];
|
lines: JournalEntryLine[];
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -82,7 +82,7 @@ export class JournalEntry {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@ -90,13 +90,13 @@ export class JournalEntry {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'posted_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'posted_at' })
|
||||||
postedAt: Date | null;
|
postedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'posted_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'posted_by' })
|
||||||
postedBy: string | null;
|
postedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'cancelled_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'cancelled_at' })
|
||||||
cancelledAt: Date | null;
|
cancelledAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'cancelled_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'cancelled_by' })
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export class Journal {
|
|||||||
defaultAccount: Account | null;
|
defaultAccount: Account | null;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -78,7 +78,7 @@ export class Journal {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@ -86,7 +86,7 @@ export class Journal {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -111,7 +111,7 @@ export class Payment {
|
|||||||
journalEntry: JournalEntry | null;
|
journalEntry: JournalEntry | null;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -119,7 +119,7 @@ export class Payment {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@ -127,7 +127,7 @@ export class Payment {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'posted_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'posted_at' })
|
||||||
postedAt: Date | null;
|
postedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'posted_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'posted_by' })
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export class Tax {
|
|||||||
company: Company;
|
company: Company;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -68,7 +68,7 @@ export class Tax {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@ -75,6 +75,6 @@ export class InventoryAdjustmentLine {
|
|||||||
lot: Lot | null;
|
lot: Lot | null;
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export class InventoryAdjustment {
|
|||||||
lines: InventoryAdjustmentLine[];
|
lines: InventoryAdjustmentLine[];
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -76,7 +76,7 @@ export class InventoryAdjustment {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@ -78,7 +78,7 @@ export class Location {
|
|||||||
stockQuants: StockQuant[];
|
stockQuants: StockQuant[];
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -86,7 +86,7 @@ export class Location {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export class Lot {
|
|||||||
stockQuants: StockQuant[];
|
stockQuants: StockQuant[];
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
|||||||
@ -64,10 +64,10 @@ export class Picking {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
|
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
|
||||||
partnerId: string | null;
|
partnerId: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'scheduled_date' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'scheduled_date' })
|
||||||
scheduledDate: Date | null;
|
scheduledDate: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'date_done' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'date_done' })
|
||||||
dateDone: Date | null;
|
dateDone: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
@ -84,7 +84,7 @@ export class Picking {
|
|||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'validated_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'validated_at' })
|
||||||
validatedAt: Date | null;
|
validatedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'validated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'validated_by' })
|
||||||
@ -107,7 +107,7 @@ export class Picking {
|
|||||||
moves: StockMove[];
|
moves: StockMove[];
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -115,7 +115,7 @@ export class Picking {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@ -147,7 +147,7 @@ export class Product {
|
|||||||
lots: Lot[];
|
lots: Lot[];
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -155,7 +155,7 @@ export class Product {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@ -163,7 +163,7 @@ export class Product {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'deleted_at' })
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export class StockMove {
|
|||||||
})
|
})
|
||||||
status: MoveStatus;
|
status: MoveStatus;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true })
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
date: Date | null;
|
date: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
@ -86,7 +86,7 @@ export class StockMove {
|
|||||||
lot: Lot | null;
|
lot: Lot | null;
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -94,7 +94,7 @@ export class StockMove {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@ -54,12 +54,12 @@ export class StockQuant {
|
|||||||
lot: Lot | null;
|
lot: Lot | null;
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export class StockValuationLayer {
|
|||||||
company: Company;
|
company: Company;
|
||||||
|
|
||||||
// Auditoría
|
// Auditoría
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -75,7 +75,7 @@ export class StockValuationLayer {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@ -5,13 +5,13 @@ Este modulo esta deprecated desde 2026-02-03.
|
|||||||
## Razon
|
## Razon
|
||||||
|
|
||||||
La funcionalidad de facturacion esta siendo consolidada en el modulo `financial`.
|
La funcionalidad de facturacion esta siendo consolidada en el modulo `financial`.
|
||||||
El modulo `invoices` (schema `billing`) fue disenado para facturacion operativa,
|
El modulo `invoices` (schema `operations`, formerly `billing`) fue disenado para facturacion operativa,
|
||||||
pero para mantener una arquitectura limpia, toda la logica de facturas debe
|
pero para mantener una arquitectura limpia, toda la logica de facturas debe
|
||||||
residir en un unico modulo.
|
residir en un unico modulo.
|
||||||
|
|
||||||
## Mapeo de Entidades
|
## Mapeo de Entidades
|
||||||
|
|
||||||
| Invoices (billing) | Financial (financial) | Notas |
|
| Invoices (operations) | Financial (financial) | Notas |
|
||||||
|------------------------|------------------------------|------------------------------------------|
|
|------------------------|------------------------------|------------------------------------------|
|
||||||
| Invoice | financial/invoice.entity.ts | financial tiene integracion con journals |
|
| Invoice | financial/invoice.entity.ts | financial tiene integracion con journals |
|
||||||
| InvoiceItem | financial/invoice-line.entity.ts | Renombrado a InvoiceLine |
|
| InvoiceItem | financial/invoice-line.entity.ts | Renombrado a InvoiceLine |
|
||||||
@ -21,8 +21,8 @@ residir en un unico modulo.
|
|||||||
## Diferencias Clave
|
## Diferencias Clave
|
||||||
|
|
||||||
### Invoices (deprecated)
|
### Invoices (deprecated)
|
||||||
- Schema: `billing`
|
- Schema: `operations` (moved from `billing` to resolve conflict with SaaS billing schema)
|
||||||
- Enfoque: Facturacion operativa, CFDI Mexico, SaaS billing
|
- Enfoque: Facturacion operativa, CFDI Mexico
|
||||||
- Sin integracion contable directa
|
- Sin integracion contable directa
|
||||||
|
|
||||||
### Financial (activo)
|
### Financial (activo)
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { Invoice } from './invoice.entity';
|
|||||||
* @deprecated Since 2026-02-03. Use financial/invoice-line.entity.ts instead.
|
* @deprecated Since 2026-02-03. Use financial/invoice-line.entity.ts instead.
|
||||||
* @see InvoiceLine from '@modules/financial/entities'
|
* @see InvoiceLine from '@modules/financial/entities'
|
||||||
*/
|
*/
|
||||||
@Entity({ name: 'invoice_items', schema: 'billing' })
|
@Entity({ name: 'invoice_items', schema: 'operations' })
|
||||||
export class InvoiceItem {
|
export class InvoiceItem {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { InvoiceItem } from './invoice-item.entity';
|
|||||||
* Unified Invoice Entity
|
* Unified Invoice Entity
|
||||||
*
|
*
|
||||||
* Combines fields from commercial invoices and SaaS billing invoices.
|
* Combines fields from commercial invoices and SaaS billing invoices.
|
||||||
* Schema: billing
|
* Schema: operations
|
||||||
*
|
*
|
||||||
* Context discriminator:
|
* Context discriminator:
|
||||||
* - 'commercial': Sales/purchase invoices (salesOrderId, purchaseOrderId, partnerId)
|
* - 'commercial': Sales/purchase invoices (salesOrderId, purchaseOrderId, partnerId)
|
||||||
@ -28,7 +28,7 @@ export type InvoiceType = 'sale' | 'purchase' | 'credit_note' | 'debit_note';
|
|||||||
export type InvoiceStatus = 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'overdue' | 'void' | 'refunded' | 'cancelled' | 'voided';
|
export type InvoiceStatus = 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'overdue' | 'void' | 'refunded' | 'cancelled' | 'voided';
|
||||||
export type InvoiceContext = 'commercial' | 'saas';
|
export type InvoiceContext = 'commercial' | 'saas';
|
||||||
|
|
||||||
@Entity({ name: 'invoices', schema: 'billing' })
|
@Entity({ name: 'invoices', schema: 'operations' })
|
||||||
export class Invoice {
|
export class Invoice {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { Invoice } from './invoice.entity';
|
|||||||
* @deprecated Since 2026-02-03. Use payment_invoices relationship in financial module.
|
* @deprecated Since 2026-02-03. Use payment_invoices relationship in financial module.
|
||||||
* @see Payment from '@modules/financial/entities'
|
* @see Payment from '@modules/financial/entities'
|
||||||
*/
|
*/
|
||||||
@Entity({ name: 'payment_allocations', schema: 'billing' })
|
@Entity({ name: 'payment_allocations', schema: 'operations' })
|
||||||
export class PaymentAllocation {
|
export class PaymentAllocation {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateCol
|
|||||||
* @deprecated Since 2026-02-03. Use financial/payment.entity.ts instead.
|
* @deprecated Since 2026-02-03. Use financial/payment.entity.ts instead.
|
||||||
* @see Payment from '@modules/financial/entities'
|
* @see Payment from '@modules/financial/entities'
|
||||||
*/
|
*/
|
||||||
@Entity({ name: 'payments', schema: 'billing' })
|
@Entity({ name: 'payments', schema: 'operations' })
|
||||||
export class Payment {
|
export class Payment {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
392
src/modules/mobile/mobile.controller.ts
Normal file
392
src/modules/mobile/mobile.controller.ts
Normal 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();
|
||||||
63
src/modules/mobile/mobile.routes.ts
Normal file
63
src/modules/mobile/mobile.routes.ts
Normal 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;
|
||||||
9
src/modules/mobile/services/index.ts
Normal file
9
src/modules/mobile/services/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
mobileService,
|
||||||
|
CreateSessionDto,
|
||||||
|
SessionFilters,
|
||||||
|
RegisterPushTokenDto,
|
||||||
|
UpdatePushTokenDto,
|
||||||
|
AddToQueueDto,
|
||||||
|
QueueFilters,
|
||||||
|
} from './mobile.service.js';
|
||||||
661
src/modules/mobile/services/mobile.service.ts
Normal file
661
src/modules/mobile/services/mobile.service.ts
Normal 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();
|
||||||
@ -209,36 +209,21 @@ export class CreatePartnerAddressDto {
|
|||||||
partnerId: string;
|
partnerId: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['billing', 'shipping', 'both'])
|
@IsEnum(['billing', 'shipping', 'main', 'other'])
|
||||||
addressType?: 'billing' | 'shipping' | 'both';
|
addressType?: 'billing' | 'shipping' | 'main' | 'other';
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(100)
|
|
||||||
label?: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
street: string;
|
addressLine1: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(20)
|
@MaxLength(200)
|
||||||
exteriorNumber?: string;
|
addressLine2?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
interiorNumber?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(100)
|
|
||||||
neighborhood?: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
@ -247,21 +232,32 @@ export class CreatePartnerAddressDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
municipality?: string;
|
state?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(20)
|
||||||
state: string;
|
postalCode?: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(10)
|
|
||||||
postalCode: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(3)
|
@MaxLength(3)
|
||||||
country?: string;
|
country?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
contactName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
contactPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
contactEmail?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
reference?: string;
|
reference?: string;
|
||||||
@ -273,6 +269,10 @@ export class CreatePartnerAddressDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreatePartnerContactDto {
|
export class CreatePartnerContactDto {
|
||||||
@ -281,12 +281,12 @@ export class CreatePartnerContactDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
fullName: string;
|
name: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
position?: string;
|
jobTitle?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -308,9 +308,8 @@ export class CreatePartnerContactDto {
|
|||||||
mobile?: string;
|
mobile?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsEnum(['general', 'billing', 'purchasing', 'sales', 'technical'])
|
||||||
@MaxLength(30)
|
contactType?: 'general' | 'billing' | 'purchasing' | 'sales' | 'technical';
|
||||||
extension?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@ -318,15 +317,7 @@ export class CreatePartnerContactDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isBillingContact?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isShippingContact?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
receivesNotifications?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@ -4,18 +4,27 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
Index,
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Partner } from './partner.entity';
|
import { Partner } from './partner.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PartnerAddress Entity
|
||||||
|
*
|
||||||
|
* Synchronized with DDL: database/ddl/16-partners.sql
|
||||||
|
* Table: partners.partner_addresses
|
||||||
|
*
|
||||||
|
* Represents addresses associated with a partner (billing, shipping, etc.).
|
||||||
|
*/
|
||||||
@Entity({ name: 'partner_addresses', schema: 'partners' })
|
@Entity({ name: 'partner_addresses', schema: 'partners' })
|
||||||
export class PartnerAddress {
|
export class PartnerAddress {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Index()
|
@Index('idx_partner_addresses_partner')
|
||||||
@Column({ name: 'partner_id', type: 'uuid' })
|
@Column({ name: 'partner_id', type: 'uuid' })
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
|
|
||||||
@ -24,54 +33,56 @@ export class PartnerAddress {
|
|||||||
partner: Partner;
|
partner: Partner;
|
||||||
|
|
||||||
// Tipo de direccion
|
// Tipo de direccion
|
||||||
@Index()
|
@Index('idx_partner_addresses_type')
|
||||||
@Column({ name: 'address_type', type: 'varchar', length: 20, default: 'billing' })
|
@Column({ name: 'address_type', type: 'varchar', length: 20, default: 'billing' })
|
||||||
addressType: 'billing' | 'shipping' | 'both';
|
addressType: 'billing' | 'shipping' | 'main' | 'other';
|
||||||
|
|
||||||
@Column({ name: 'is_default', type: 'boolean', default: false })
|
|
||||||
isDefault: boolean;
|
|
||||||
|
|
||||||
// Direccion
|
// Direccion
|
||||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
@Column({ name: 'address_line1', type: 'varchar', length: 200 })
|
||||||
label: string;
|
addressLine1: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 200 })
|
@Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true })
|
||||||
street: string;
|
addressLine2: string | null;
|
||||||
|
|
||||||
@Column({ name: 'exterior_number', type: 'varchar', length: 20, nullable: true })
|
|
||||||
exteriorNumber: string;
|
|
||||||
|
|
||||||
@Column({ name: 'interior_number', type: 'varchar', length: 20, nullable: true })
|
|
||||||
interiorNumber: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
|
||||||
neighborhood: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 100 })
|
@Column({ type: 'varchar', length: 100 })
|
||||||
city: string;
|
city: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
municipality: string;
|
state: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 100 })
|
@Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true })
|
||||||
state: string;
|
postalCode: string | null;
|
||||||
|
|
||||||
@Column({ name: 'postal_code', type: 'varchar', length: 10 })
|
|
||||||
postalCode: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 3, default: 'MEX' })
|
@Column({ type: 'varchar', length: 3, default: 'MEX' })
|
||||||
country: string;
|
country: string;
|
||||||
|
|
||||||
|
// Contacto en esta direccion
|
||||||
|
@Column({ name: 'contact_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
contactName: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'contact_phone', type: 'varchar', length: 30, nullable: true })
|
||||||
|
contactPhone: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'contact_email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
contactEmail: string | null;
|
||||||
|
|
||||||
// Referencia
|
// Referencia
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
reference: string;
|
reference: string | null;
|
||||||
|
|
||||||
// Geolocalizacion
|
// Geolocalizacion
|
||||||
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
latitude: number;
|
latitude: number | null;
|
||||||
|
|
||||||
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
longitude: number;
|
longitude: number | null;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_default', type: 'boolean', default: false })
|
||||||
|
isDefault: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
@ -79,4 +90,7 @@ export class PartnerAddress {
|
|||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,18 +4,27 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
Index,
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Partner } from './partner.entity';
|
import { Partner } from './partner.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PartnerContact Entity
|
||||||
|
*
|
||||||
|
* Synchronized with DDL: database/ddl/16-partners.sql
|
||||||
|
* Table: partners.partner_contacts
|
||||||
|
*
|
||||||
|
* Represents individual contact persons of a partner.
|
||||||
|
*/
|
||||||
@Entity({ name: 'partner_contacts', schema: 'partners' })
|
@Entity({ name: 'partner_contacts', schema: 'partners' })
|
||||||
export class PartnerContact {
|
export class PartnerContact {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Index()
|
@Index('idx_partner_contacts_partner')
|
||||||
@Column({ name: 'partner_id', type: 'uuid' })
|
@Column({ name: 'partner_id', type: 'uuid' })
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
|
|
||||||
@ -23,45 +32,42 @@ export class PartnerContact {
|
|||||||
@JoinColumn({ name: 'partner_id' })
|
@JoinColumn({ name: 'partner_id' })
|
||||||
partner: Partner;
|
partner: Partner;
|
||||||
|
|
||||||
// Datos del contacto
|
// Datos personales
|
||||||
@Column({ name: 'full_name', type: 'varchar', length: 200 })
|
@Column({ type: 'varchar', length: 200 })
|
||||||
fullName: string;
|
name: string;
|
||||||
|
|
||||||
|
@Column({ name: 'job_title', type: 'varchar', length: 100, nullable: true })
|
||||||
|
jobTitle: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
position: string;
|
department: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
|
||||||
department: string;
|
|
||||||
|
|
||||||
// Contacto
|
// Contacto
|
||||||
|
@Index('idx_partner_contacts_email')
|
||||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
email: string;
|
email: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 30, nullable: true })
|
@Column({ type: 'varchar', length: 30, nullable: true })
|
||||||
phone: string;
|
phone: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 30, nullable: true })
|
@Column({ type: 'varchar', length: 30, nullable: true })
|
||||||
mobile: string;
|
mobile: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 30, nullable: true })
|
// Rol
|
||||||
extension: string;
|
@Index('idx_partner_contacts_type')
|
||||||
|
@Column({ name: 'contact_type', type: 'varchar', length: 20, default: 'general' })
|
||||||
|
contactType: 'general' | 'billing' | 'purchasing' | 'sales' | 'technical';
|
||||||
|
|
||||||
// Flags
|
|
||||||
@Column({ name: 'is_primary', type: 'boolean', default: false })
|
@Column({ name: 'is_primary', type: 'boolean', default: false })
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
|
|
||||||
@Column({ name: 'is_billing_contact', type: 'boolean', default: false })
|
// Estado
|
||||||
isBillingContact: boolean;
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
@Column({ name: 'is_shipping_contact', type: 'boolean', default: false })
|
|
||||||
isShippingContact: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'receives_notifications', type: 'boolean', default: true })
|
|
||||||
receivesNotifications: boolean;
|
|
||||||
|
|
||||||
// Notas
|
// Notas
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
notes: string;
|
notes: string | null;
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
@ -69,4 +75,7 @@ export class PartnerContact {
|
|||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,6 @@
|
|||||||
|
export * from './project.entity.js';
|
||||||
|
export * from './project-stage.entity.js';
|
||||||
|
export * from './task.entity.js';
|
||||||
|
export * from './milestone.entity.js';
|
||||||
|
export * from './project-member.entity.js';
|
||||||
export * from './timesheet.entity.js';
|
export * from './timesheet.entity.js';
|
||||||
|
|||||||
64
src/modules/projects/entities/milestone.entity.ts
Normal file
64
src/modules/projects/entities/milestone.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
34
src/modules/projects/entities/project-member.entity.ts
Normal file
34
src/modules/projects/entities/project-member.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
46
src/modules/projects/entities/project-stage.entity.ts
Normal file
46
src/modules/projects/entities/project-stage.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
111
src/modules/projects/entities/project.entity.ts
Normal file
111
src/modules/projects/entities/project.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
110
src/modules/projects/entities/task.entity.ts
Normal file
110
src/modules/projects/entities/task.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -73,11 +73,11 @@ export class TimesheetEntity {
|
|||||||
@Column({ type: 'uuid', nullable: true, name: 'approved_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'approved_by' })
|
||||||
approvedBy: string | null;
|
approvedBy: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', nullable: true, name: 'approved_at' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'approved_at' })
|
||||||
approvedAt: Date | null;
|
approvedAt: Date | null;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
@ -85,7 +85,7 @@ export class TimesheetEntity {
|
|||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamp',
|
type: 'timestamptz',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@ -26,8 +26,8 @@ export class PermissionsController {
|
|||||||
const params: PaginationParams = { page, limit, sortBy, sortOrder };
|
const params: PaginationParams = { page, limit, sortBy, sortOrder };
|
||||||
|
|
||||||
// Build filter
|
// Build filter
|
||||||
const filter: { module?: string; resource?: string; action?: PermissionAction } = {};
|
const filter: { category?: string; resource?: string; action?: PermissionAction } = {};
|
||||||
if (req.query.module) filter.module = req.query.module as string;
|
if (req.query.category) filter.category = req.query.category as string;
|
||||||
if (req.query.resource) filter.resource = req.query.resource as string;
|
if (req.query.resource) filter.resource = req.query.resource as string;
|
||||||
if (req.query.action) filter.action = req.query.action as PermissionAction;
|
if (req.query.action) filter.action = req.query.action as PermissionAction;
|
||||||
|
|
||||||
@ -53,9 +53,9 @@ export class PermissionsController {
|
|||||||
/**
|
/**
|
||||||
* GET /permissions/modules - Get list of all modules
|
* GET /permissions/modules - Get list of all modules
|
||||||
*/
|
*/
|
||||||
async getModules(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const modules = await permissionsService.getModules();
|
const modules = await permissionsService.getCategories();
|
||||||
|
|
||||||
const response: ApiResponse = {
|
const response: ApiResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
@ -91,7 +91,7 @@ export class PermissionsController {
|
|||||||
*/
|
*/
|
||||||
async getGrouped(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
async getGrouped(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const grouped = await permissionsService.getGroupedByModule();
|
const grouped = await permissionsService.getGroupedByCategory();
|
||||||
|
|
||||||
const response: ApiResponse = {
|
const response: ApiResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
@ -107,10 +107,10 @@ export class PermissionsController {
|
|||||||
/**
|
/**
|
||||||
* GET /permissions/by-module/:module - Get all permissions for a module
|
* GET /permissions/by-module/:module - Get all permissions for a module
|
||||||
*/
|
*/
|
||||||
async getByModule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
async getByCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const module = req.params.module;
|
const category = req.params.category;
|
||||||
const permissions = await permissionsService.getByModule(module);
|
const permissions = await permissionsService.getByCategory(category);
|
||||||
|
|
||||||
const response: ApiResponse = {
|
const response: ApiResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@ -23,9 +23,9 @@ router.get('/', requireAccess({ roles: ['admin', 'manager'], permission: 'permis
|
|||||||
permissionsController.findAll(req, res, next)
|
permissionsController.findAll(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get available modules (admin, manager)
|
// Get available categories (admin, manager)
|
||||||
router.get('/modules', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) =>
|
router.get('/categories', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) =>
|
||||||
permissionsController.getModules(req, res, next)
|
permissionsController.getCategories(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get available resources (admin, manager)
|
// Get available resources (admin, manager)
|
||||||
@ -38,9 +38,9 @@ router.get('/grouped', requireAccess({ roles: ['admin', 'manager'], permission:
|
|||||||
permissionsController.getGrouped(req, res, next)
|
permissionsController.getGrouped(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get permissions by module (admin, manager)
|
// Get permissions by category (admin, manager)
|
||||||
router.get('/by-module/:module', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) =>
|
router.get('/by-category/:category', requireAccess({ roles: ['admin', 'manager'], permission: 'permissions:read' }), (req, res, next) =>
|
||||||
permissionsController.getByModule(req, res, next)
|
permissionsController.getByCategory(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get permission matrix for admin UI (admin only)
|
// Get permission matrix for admin UI (admin only)
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { logger } from '../../../shared/utils/logger.js';
|
|||||||
// ===== Interfaces =====
|
// ===== Interfaces =====
|
||||||
|
|
||||||
export interface PermissionFilter {
|
export interface PermissionFilter {
|
||||||
module?: string;
|
category?: string;
|
||||||
resource?: string;
|
resource?: string;
|
||||||
action?: PermissionAction;
|
action?: PermissionAction;
|
||||||
}
|
}
|
||||||
@ -15,7 +15,7 @@ export interface PermissionFilter {
|
|||||||
export interface EffectivePermission {
|
export interface EffectivePermission {
|
||||||
resource: string;
|
resource: string;
|
||||||
action: string;
|
action: string;
|
||||||
module: string | null;
|
category: string | null;
|
||||||
fromRoles: string[];
|
fromRoles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,8 +50,8 @@ class PermissionsService {
|
|||||||
.take(limit);
|
.take(limit);
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if (filter?.module) {
|
if (filter?.category) {
|
||||||
queryBuilder.andWhere('permission.module = :module', { module: filter.module });
|
queryBuilder.andWhere('permission.category = :category', { category: filter.category });
|
||||||
}
|
}
|
||||||
if (filter?.resource) {
|
if (filter?.resource) {
|
||||||
queryBuilder.andWhere('permission.resource LIKE :resource', {
|
queryBuilder.andWhere('permission.resource LIKE :resource', {
|
||||||
@ -96,23 +96,23 @@ class PermissionsService {
|
|||||||
/**
|
/**
|
||||||
* Get all unique modules
|
* Get all unique modules
|
||||||
*/
|
*/
|
||||||
async getModules(): Promise<string[]> {
|
async getCategories(): Promise<string[]> {
|
||||||
const result = await this.permissionRepository
|
const result = await this.permissionRepository
|
||||||
.createQueryBuilder('permission')
|
.createQueryBuilder('permission')
|
||||||
.select('DISTINCT permission.module', 'module')
|
.select('DISTINCT permission.category', 'category')
|
||||||
.where('permission.module IS NOT NULL')
|
.where('permission.category IS NOT NULL')
|
||||||
.orderBy('permission.module', 'ASC')
|
.orderBy('permission.category', 'ASC')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
return result.map(r => r.module);
|
return result.map(r => r.category);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all permissions for a specific module
|
* Get all permissions for a specific module
|
||||||
*/
|
*/
|
||||||
async getByModule(module: string): Promise<Permission[]> {
|
async getByCategory(category: string): Promise<Permission[]> {
|
||||||
return await this.permissionRepository.find({
|
return await this.permissionRepository.find({
|
||||||
where: { module },
|
where: { category },
|
||||||
order: { resource: 'ASC', action: 'ASC' },
|
order: { resource: 'ASC', action: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -133,19 +133,19 @@ class PermissionsService {
|
|||||||
/**
|
/**
|
||||||
* Get permissions grouped by module
|
* Get permissions grouped by module
|
||||||
*/
|
*/
|
||||||
async getGroupedByModule(): Promise<Record<string, Permission[]>> {
|
async getGroupedByCategory(): Promise<Record<string, Permission[]>> {
|
||||||
const permissions = await this.permissionRepository.find({
|
const permissions = await this.permissionRepository.find({
|
||||||
order: { module: 'ASC', resource: 'ASC', action: 'ASC' },
|
order: { category: 'ASC', resource: 'ASC', action: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const grouped: Record<string, Permission[]> = {};
|
const grouped: Record<string, Permission[]> = {};
|
||||||
|
|
||||||
for (const permission of permissions) {
|
for (const permission of permissions) {
|
||||||
const module = permission.module || 'other';
|
const cat = permission.category || 'other';
|
||||||
if (!grouped[module]) {
|
if (!grouped[cat]) {
|
||||||
grouped[module] = [];
|
grouped[cat] = [];
|
||||||
}
|
}
|
||||||
grouped[module].push(permission);
|
grouped[cat].push(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
return grouped;
|
return grouped;
|
||||||
@ -188,7 +188,7 @@ class PermissionsService {
|
|||||||
permissionMap.set(key, {
|
permissionMap.set(key, {
|
||||||
resource: permission.resource,
|
resource: permission.resource,
|
||||||
action: permission.action,
|
action: permission.action,
|
||||||
module: permission.module,
|
category: permission.category,
|
||||||
fromRoles: [role.name],
|
fromRoles: [role.name],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -243,7 +243,7 @@ class PermissionsService {
|
|||||||
if (role.deletedAt) continue;
|
if (role.deletedAt) continue;
|
||||||
|
|
||||||
// Super admin role has all permissions
|
// Super admin role has all permissions
|
||||||
if (role.code === 'super_admin') {
|
if (role.isSuperadmin) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -299,7 +299,7 @@ class PermissionsService {
|
|||||||
async getPermissionMatrix(
|
async getPermissionMatrix(
|
||||||
tenantId: string
|
tenantId: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
roles: Array<{ id: string; name: string; code: string }>;
|
roles: Array<{ id: string; name: string }>;
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
matrix: Record<string, string[]>;
|
matrix: Record<string, string[]>;
|
||||||
}> {
|
}> {
|
||||||
@ -313,7 +313,7 @@ class PermissionsService {
|
|||||||
|
|
||||||
// Get all permissions
|
// Get all permissions
|
||||||
const permissions = await this.permissionRepository.find({
|
const permissions = await this.permissionRepository.find({
|
||||||
order: { module: 'ASC', resource: 'ASC', action: 'ASC' },
|
order: { category: 'ASC', resource: 'ASC', action: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build matrix: roleId -> [permissionIds]
|
// Build matrix: roleId -> [permissionIds]
|
||||||
@ -323,7 +323,7 @@ class PermissionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
roles: roles.map(r => ({ id: r.id, name: r.name, code: r.code })),
|
roles: roles.map(r => ({ id: r.id, name: r.name })),
|
||||||
permissions,
|
permissions,
|
||||||
matrix,
|
matrix,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { logger } from '../../../shared/utils/logger.js';
|
|||||||
|
|
||||||
export interface CreateRoleDto {
|
export interface CreateRoleDto {
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
permissionIds?: string[];
|
permissionIds?: string[];
|
||||||
@ -99,23 +98,23 @@ class RolesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a role by code
|
* Get a role by name
|
||||||
*/
|
*/
|
||||||
async findByCode(tenantId: string, code: string): Promise<Role | null> {
|
async findByName(tenantId: string, name: string): Promise<Role | null> {
|
||||||
try {
|
try {
|
||||||
return await this.roleRepository.findOne({
|
return await this.roleRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
code,
|
name,
|
||||||
tenantId,
|
tenantId,
|
||||||
deletedAt: undefined,
|
deletedAt: undefined,
|
||||||
},
|
},
|
||||||
relations: ['permissions'],
|
relations: ['permissions'],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error finding role by code', {
|
logger.error('Error finding role by name', {
|
||||||
error: (error as Error).message,
|
error: (error as Error).message,
|
||||||
tenantId,
|
tenantId,
|
||||||
code,
|
name,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -130,22 +129,16 @@ class RolesService {
|
|||||||
createdBy: string
|
createdBy: string
|
||||||
): Promise<Role> {
|
): Promise<Role> {
|
||||||
try {
|
try {
|
||||||
// Validate code uniqueness within tenant
|
// Validate name uniqueness within tenant
|
||||||
const existing = await this.findByCode(tenantId, data.code);
|
const existing = await this.findByName(tenantId, data.name);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ValidationError('Ya existe un rol con este código');
|
throw new ValidationError('Ya existe un rol con este nombre');
|
||||||
}
|
|
||||||
|
|
||||||
// Validate code format
|
|
||||||
if (!/^[a-z_]+$/.test(data.code)) {
|
|
||||||
throw new ValidationError('El código debe contener solo letras minúsculas y guiones bajos');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create role
|
// Create role
|
||||||
const role = this.roleRepository.create({
|
const role = this.roleRepository.create({
|
||||||
tenantId,
|
tenantId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
code: data.code,
|
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
color: data.color || null,
|
color: data.color || null,
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
@ -165,7 +158,7 @@ class RolesService {
|
|||||||
logger.info('Role created', {
|
logger.info('Role created', {
|
||||||
roleId: role.id,
|
roleId: role.id,
|
||||||
tenantId,
|
tenantId,
|
||||||
code: role.code,
|
name: role.name,
|
||||||
createdBy,
|
createdBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -251,7 +244,6 @@ class RolesService {
|
|||||||
|
|
||||||
// Soft delete
|
// Soft delete
|
||||||
role.deletedAt = new Date();
|
role.deletedAt = new Date();
|
||||||
role.deletedBy = deletedBy;
|
|
||||||
|
|
||||||
await this.roleRepository.save(role);
|
await this.roleRepository.save(role);
|
||||||
|
|
||||||
|
|||||||
@ -82,13 +82,13 @@ export class StorageFile {
|
|||||||
@Column({ name: 'thumbnail_url', type: 'text', nullable: true })
|
@Column({ name: 'thumbnail_url', type: 'text', nullable: true })
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
|
||||||
@Column({ name: 'thumbnails', type: 'jsonb', default: {} })
|
@Column({ name: 'thumbnails', type: 'jsonb', default: '{}' })
|
||||||
thumbnails: Record<string, string>;
|
thumbnails: Record<string, string>;
|
||||||
|
|
||||||
@Column({ name: 'metadata', type: 'jsonb', default: {} })
|
@Column({ name: 'metadata', type: 'jsonb', default: '{}' })
|
||||||
metadata: Record<string, any>;
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
@Column({ name: 'tags', type: 'text', array: true, default: [] })
|
@Column({ name: 'tags', type: 'text', array: true, default: '{}' })
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
|
||||||
@Column({ name: 'alt_text', type: 'text', nullable: true })
|
@Column({ name: 'alt_text', type: 'text', nullable: true })
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken';
|
|||||||
import { config } from '../../config/index.js';
|
import { config } from '../../config/index.js';
|
||||||
import { AuthenticatedRequest, JwtPayload, UnauthorizedError, ForbiddenError } from '../types/index.js';
|
import { AuthenticatedRequest, JwtPayload, UnauthorizedError, ForbiddenError } from '../types/index.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { permissionsService } from '../../modules/roles/permissions.service.js';
|
import { permissionsService } from '../../modules/roles/services/permissions.service.js';
|
||||||
import {
|
import {
|
||||||
checkCachedPermission,
|
checkCachedPermission,
|
||||||
getCachedPermissions,
|
getCachedPermissions,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { AuthenticatedRequest, UnauthorizedError, ForbiddenError } from '../types/index.js';
|
import { AuthenticatedRequest, UnauthorizedError, ForbiddenError } from '../types/index.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { permissionsService } from '../../modules/roles/permissions.service.js';
|
import { permissionsService } from '../../modules/roles/services/permissions.service.js';
|
||||||
import {
|
import {
|
||||||
checkCachedPermission,
|
checkCachedPermission,
|
||||||
getCachedPermissions,
|
getCachedPermissions,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { redisClient, isRedisConnected } from '../../config/redis.js';
|
import { redisClient, isRedisConnected } from '../../config/redis.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { EffectivePermission } from '../../modules/roles/permissions.service.js';
|
import { EffectivePermission } from '../../modules/roles/services/permissions.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for caching user permissions in Redis
|
* Service for caching user permissions in Redis
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user