fix: Resolve TypeScript build errors

- Replace class-validator with plain TypeScript interfaces in AI DTOs
- Fix AIModel field names (inputCostPer1k instead of inputCostPer1m)
- Fix AIUsageLog field mapping (promptTokens, completionTokens, cost)
- Add 'branches', 'financial', 'sales' to MCP ToolCategory
- Add branchId to McpContext interface
- Create stub entities for BranchPaymentTerminal and PaymentTransaction
- Create CircuitBreaker utility for payment transactions
- Create database config and error classes for base.service.ts
- Fix type assertions for fetch response.json() calls
- Align PaymentStatus and PaymentMethod types between entities and DTOs
- Add @types/pg dependency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-27 07:54:42 -06:00
parent dad6575a3c
commit a0ca2bd77a
20 changed files with 601 additions and 323 deletions

13
package-lock.json generated
View File

@ -33,6 +33,7 @@
"@types/jsonwebtoken": "^9.0.5",
"@types/morgan": "^1.9.9",
"@types/node": "^20.10.0",
"@types/pg": "^8.16.0",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
@ -1656,6 +1657,18 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",

View File

@ -15,38 +15,39 @@
"db:migrate:revert": "typeorm migration:revert"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"typeorm": "^0.3.17",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.1",
"dotenv": "^16.3.1",
"zod": "^3.22.4",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"typeorm": "^0.3.17",
"uuid": "^9.0.1",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"compression": "^1.7.4",
"morgan": "^1.10.0",
"winston": "^3.11.0"
"winston": "^3.11.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.5",
"@types/uuid": "^9.0.7",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/morgan": "^1.9.9",
"typescript": "^5.3.3",
"ts-node-dev": "^2.0.0",
"eslint": "^8.55.0",
"@types/node": "^20.10.0",
"@types/pg": "^8.16.0",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.55.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.11",
"ts-jest": "^29.1.1"
"ts-jest": "^29.1.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=18.0.0"

67
src/config/database.ts Normal file
View File

@ -0,0 +1,67 @@
/**
* Database Configuration and Query Helpers
* Stub implementation for base.service.ts compatibility
*/
import { Pool, PoolClient, QueryResult } from 'pg';
// Connection pool (initialized lazily)
let pool: Pool | null = null;
/**
* Get or create database pool
*/
function getPool(): Pool {
if (!pool) {
pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'mecanicas_diesel',
user: process.env.DB_USER || 'mecanicas_user',
password: process.env.DB_PASSWORD || 'mecanicas_dev_2026',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
}
return pool;
}
/**
* Execute a query and return rows
*/
export async function query<T = any>(sql: string, params?: any[]): Promise<T[]> {
const client = await getPool().connect();
try {
const result = await client.query(sql, params);
return result.rows as T[];
} finally {
client.release();
}
}
/**
* Execute a query and return a single row
*/
export async function queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> {
const rows = await query<T>(sql, params);
return rows[0] || null;
}
/**
* Get a client from the pool
*/
export async function getClient(): Promise<PoolClient> {
return getPool().connect();
}
/**
* Close the pool
*/
export async function closePool(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}
export { PoolClient };

View File

@ -1,133 +1,39 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsArray,
IsObject,
IsUUID,
MaxLength,
MinLength,
Min,
Max,
} from 'class-validator';
/**
* AI DTOs
* Using plain TypeScript interfaces (Zod for runtime validation)
*/
// ============================================
// PROMPT DTOs
// ============================================
export class CreatePromptDto {
@IsString()
@MinLength(2)
@MaxLength(50)
export interface CreatePromptDto {
code: string;
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(30)
category?: string;
@IsString()
systemPrompt: string;
@IsOptional()
@IsString()
userPromptTemplate?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
variables?: string[];
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
stopSequences?: string[];
@IsOptional()
@IsObject()
modelParameters?: Record<string, any>;
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedModels?: string[];
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class UpdatePromptDto {
@IsOptional()
@IsString()
@MaxLength(100)
export interface UpdatePromptDto {
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(30)
category?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@IsOptional()
@IsString()
userPromptTemplate?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
variables?: string[];
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
stopSequences?: string[];
@IsOptional()
@IsObject()
modelParameters?: Record<string, any>;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@ -135,71 +41,23 @@ export class UpdatePromptDto {
// CONVERSATION DTOs
// ============================================
export class CreateConversationDto {
@IsOptional()
@IsUUID()
export interface CreateConversationDto {
modelId?: string;
@IsOptional()
@IsUUID()
promptId?: string;
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsObject()
context?: Record<string, any>;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class UpdateConversationDto {
@IsOptional()
@IsString()
@MaxLength(200)
export interface UpdateConversationDto {
title?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsObject()
context?: Record<string, any>;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
@ -207,46 +65,15 @@ export class UpdateConversationDto {
// MESSAGE DTOs
// ============================================
export class AddMessageDto {
@IsString()
@MaxLength(20)
export interface AddMessageDto {
role: string;
@IsString()
content: string;
@IsOptional()
@IsString()
@MaxLength(50)
modelCode?: string;
@IsOptional()
@IsNumber()
@Min(0)
promptTokens?: number;
@IsOptional()
@IsNumber()
@Min(0)
completionTokens?: number;
@IsOptional()
@IsNumber()
@Min(0)
totalTokens?: number;
@IsOptional()
@IsString()
@MaxLength(30)
finishReason?: string;
@IsOptional()
@IsNumber()
@Min(0)
latencyMs?: number;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
@ -254,50 +81,17 @@ export class AddMessageDto {
// USAGE DTOs
// ============================================
export class LogUsageDto {
@IsOptional()
@IsUUID()
export interface LogUsageDto {
userId?: string;
@IsOptional()
@IsUUID()
conversationId?: string;
@IsUUID()
modelId: string;
@IsString()
@MaxLength(20)
usageType: string;
@IsNumber()
@Min(0)
inputTokens: number;
@IsNumber()
@Min(0)
outputTokens: number;
@IsOptional()
@IsNumber()
@Min(0)
promptTokens: number;
completionTokens: number;
costUsd?: number;
@IsOptional()
@IsNumber()
@Min(0)
latencyMs?: number;
@IsOptional()
@IsBoolean()
wasSuccessful?: boolean;
@IsOptional()
@IsString()
errorMessage?: string;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
@ -305,39 +99,12 @@ export class LogUsageDto {
// QUOTA DTOs
// ============================================
export class UpdateQuotaDto {
@IsOptional()
@IsNumber()
@Min(0)
export interface UpdateQuotaDto {
maxRequestsPerMonth?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxTokensPerMonth?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxSpendPerMonth?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxRequestsPerDay?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxTokensPerDay?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedModels?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
blockedModels?: string[];
}

View File

@ -271,9 +271,10 @@ export class RoleBasedAIService extends AIService {
await this.logUsage(context.tenantId, {
modelId: model.id,
conversationId: conversation.id,
inputTokens: response.tokensUsed.input,
outputTokens: response.tokensUsed.output,
costUsd: this.calculateCost(model, response.tokensUsed),
promptTokens: response.tokensUsed.input,
completionTokens: response.tokensUsed.output,
totalTokens: response.tokensUsed.total,
cost: this.calculateCost(model, response.tokensUsed),
usageType: 'chat',
});
@ -372,7 +373,7 @@ export class RoleBasedAIService extends AIService {
'HTTP-Referer': process.env.APP_URL || 'https://erp.local',
},
body: JSON.stringify({
model: model.externalId || model.code,
model: model.modelId || model.code,
messages: messages.map((m) => ({
role: m.role,
content: m.content,
@ -393,18 +394,33 @@ export class RoleBasedAIService extends AIService {
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
const error = await response.json().catch(() => ({})) as { error?: { message?: string } };
throw new Error(error.error?.message || 'AI provider error');
}
const data = await response.json();
const data = await response.json() as {
choices?: Array<{
message?: {
content?: string;
tool_calls?: Array<{
id: string;
function?: { name: string; arguments: string };
}>;
};
}>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
const choice = data.choices?.[0];
return {
content: choice?.message?.content || '',
toolCalls: choice?.message?.tool_calls?.map((tc: any) => ({
toolCalls: choice?.message?.tool_calls?.map((tc) => ({
id: tc.id,
name: tc.function?.name,
name: tc.function?.name || '',
arguments: JSON.parse(tc.function?.arguments || '{}'),
})),
tokensUsed: {
@ -422,8 +438,8 @@ export class RoleBasedAIService extends AIService {
model: AIModel,
tokens: { input: number; output: number }
): number {
const inputCost = (tokens.input / 1000000) * (model.inputCostPer1m || 0);
const outputCost = (tokens.output / 1000000) * (model.outputCostPer1m || 0);
const inputCost = (tokens.input / 1000) * (model.inputCostPer1k || 0);
const outputCost = (tokens.output / 1000) * (model.outputCostPer1k || 0);
return inputCost + outputCost;
}

View File

@ -0,0 +1,68 @@
/**
* Branch Payment Terminal Entity (Stub)
* TODO: Full implementation
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export type TerminalProvider = 'mercadopago' | 'clip' | 'stripe_terminal';
export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
@Entity({ name: 'branch_payment_terminals', schema: 'branches' })
export class BranchPaymentTerminal {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@Column({ name: 'terminal_provider', type: 'varchar', length: 50 })
terminalProvider: TerminalProvider;
@Column({ name: 'terminal_id', type: 'varchar', length: 100 })
terminalId: string;
@Column({ name: 'terminal_name', type: 'varchar', length: 200, nullable: true })
terminalName: string | null;
@Column({ type: 'jsonb', default: {} })
credentials: Record<string, any>;
@Column({ name: 'is_primary', type: 'boolean', default: false })
isPrimary: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'daily_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
dailyLimit: number | null;
@Column({ name: 'transaction_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
transactionLimit: number | null;
@Column({ name: 'health_status', type: 'varchar', length: 20, default: 'unknown' })
healthStatus: HealthStatus;
@Column({ name: 'last_transaction_at', type: 'timestamptz', nullable: true })
lastTransactionAt: Date | null;
@Column({ name: 'last_health_check_at', type: 'timestamptz', nullable: true })
lastHealthCheckAt: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -11,6 +11,7 @@ export interface McpContext {
userId?: string;
agentId?: string;
conversationId?: string;
branchId?: string;
callerType: CallerType;
permissions: string[];
metadata?: Record<string, any>;

View File

@ -39,6 +39,9 @@ export type ToolCategory =
| 'orders'
| 'customers'
| 'fiados'
| 'branches'
| 'financial'
| 'sales'
| 'system';
export interface McpToolDefinition {

View File

@ -326,6 +326,9 @@ export class SalesToolsService implements McpToolProvider {
params: { period?: string },
context: McpContext
): Promise<any> {
if (!context.userId) {
throw new Error('User ID is required for getMySales');
}
const period = (params.period || 'today') as 'today' | 'week' | 'month';
const userSales = await this.analyticsService.getUserSales(
context.tenantId,

View File

@ -0,0 +1,112 @@
/**
* Payment Transaction Entity (Stub)
* TODO: Full implementation
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export type PaymentSourceType = 'sale' | 'service_order' | 'fiado_payment' | 'manual';
export type PaymentMethod = 'card' | 'qr' | 'link' | 'cash' | 'bank_transfer';
export type PaymentStatus =
| 'pending'
| 'processing'
| 'approved'
| 'authorized'
| 'in_process'
| 'rejected'
| 'refunded'
| 'partially_refunded'
| 'cancelled'
| 'charged_back'
| 'completed'
| 'failed';
@Entity({ name: 'payment_transactions', schema: 'mobile' })
export class PaymentTransaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string | null;
@Column({ name: 'source_type', type: 'varchar', length: 30 })
sourceType: PaymentSourceType;
@Column({ name: 'source_id', type: 'uuid' })
sourceId: string;
@Column({ name: 'terminal_provider', type: 'varchar', length: 50 })
terminalProvider: string;
@Column({ name: 'terminal_id', type: 'varchar', length: 100 })
terminalId: string;
@Column({ type: 'decimal', precision: 12, scale: 2 })
amount: number;
@Column({ type: 'varchar', length: 3, default: 'MXN' })
currency: string;
@Column({ name: 'tip_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
tipAmount: number;
@Column({ name: 'total_amount', type: 'decimal', precision: 12, scale: 2 })
totalAmount: number;
@Column({ name: 'payment_method', type: 'varchar', length: 30, default: 'card' })
paymentMethod: PaymentMethod;
@Index()
@Column({ type: 'varchar', length: 20, default: 'pending' })
status: PaymentStatus;
@Column({ name: 'external_transaction_id', type: 'varchar', length: 255, nullable: true })
externalTransactionId: string | null;
@Column({ name: 'card_brand', type: 'varchar', length: 30, nullable: true })
cardBrand: string | null;
@Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true })
cardLastFour: string | null;
@Column({ name: 'receipt_url', type: 'text', nullable: true })
receiptUrl: string | null;
@Column({ name: 'receipt_sent', type: 'boolean', default: false })
receiptSent: boolean;
@Column({ name: 'receipt_sent_to', type: 'varchar', length: 255, nullable: true })
receiptSentTo: string | null;
@Column({ name: 'failure_reason', type: 'text', nullable: true })
failureReason: string | null;
@Column({ name: 'provider_response', type: 'jsonb', nullable: true })
providerResponse: Record<string, any> | null;
@Column({ name: 'initiated_at', type: 'timestamptz', nullable: true })
initiatedAt: Date | null;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -2,7 +2,9 @@
* Terminal DTOs
*/
import { TerminalProvider, HealthStatus } from '../../branches/entities/branch-payment-terminal.entity';
// Local type definitions (based on tenant-terminal-config.entity.ts)
export type TerminalProvider = 'mercadopago' | 'clip' | 'stripe_terminal';
export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
export class CreateTerminalDto {
branchId: string;
@ -36,12 +38,12 @@ export class TerminalResponseDto {
branchId: string;
terminalProvider: TerminalProvider;
terminalId: string;
terminalName?: string;
terminalName: string | null;
isPrimary: boolean;
isActive: boolean;
dailyLimit?: number;
transactionLimit?: number;
dailyLimit: number | null;
transactionLimit: number | null;
healthStatus: HealthStatus;
lastTransactionAt?: Date;
lastHealthCheckAt?: Date;
lastTransactionAt: Date | null;
lastHealthCheckAt: Date | null;
}

View File

@ -2,7 +2,22 @@
* Transaction DTOs
*/
import { PaymentSourceType, PaymentMethod, PaymentStatus } from '../../mobile/entities/payment-transaction.entity';
// Local type definitions (based on terminal-payment.entity.ts)
export type PaymentSourceType = 'sale' | 'service_order' | 'fiado_payment' | 'manual';
export type PaymentMethod = 'card' | 'qr' | 'link' | 'cash' | 'bank_transfer';
export type PaymentStatus =
| 'pending'
| 'processing'
| 'approved'
| 'authorized'
| 'in_process'
| 'rejected'
| 'refunded'
| 'partially_refunded'
| 'cancelled'
| 'charged_back'
| 'completed'
| 'failed';
export class ProcessPaymentDto {
terminalId: string;

View File

@ -183,11 +183,11 @@ export class ClipService {
});
if (!response.ok) {
const error = await response.json();
const error = await response.json() as { message?: string };
throw new ClipError(error.message || 'Payment failed', response.status, error);
}
return response.json();
return response.json() as Promise<{ id: string; status: string; card?: { last_four: string; brand: string; type: string } }>;
});
// Actualizar registro local
@ -252,7 +252,7 @@ export class ClipService {
});
if (response.ok) {
const clipPayment = await response.json();
const clipPayment = await response.json() as { status: string };
payment.externalStatus = clipPayment.status;
payment.status = this.mapClipStatus(clipPayment.status);
payment.providerResponse = clipPayment;
@ -309,16 +309,16 @@ export class ClipService {
);
if (!response.ok) {
const error = await response.json();
const error = await response.json() as { message?: string };
throw new ClipError(error.message || 'Refund failed', response.status, error);
}
return response.json();
return response.json() as Promise<Record<string, any>>;
});
// Actualizar pago
payment.refundedAmount = Number(payment.refundedAmount || 0) + refundAmount;
payment.refundReason = dto.reason;
payment.refundReason = dto.reason ?? null;
payment.refundedAt = new Date();
if (payment.refundedAmount >= Number(payment.amount)) {
@ -361,7 +361,7 @@ export class ClipService {
});
if (!response.ok) {
const error = await response.json();
const error = await response.json() as { message?: string };
throw new ClipError(
error.message || 'Failed to create payment link',
response.status,
@ -369,7 +369,7 @@ export class ClipService {
);
}
return response.json();
return response.json() as Promise<{ url: string; id: string }>;
});
return {

View File

@ -170,23 +170,32 @@ export class MercadoPagoService {
});
if (!response.ok) {
const error = await response.json();
const error = await response.json() as { message?: string };
throw new MercadoPagoError(error.message || 'Payment failed', response.status, error);
}
return response.json();
return response.json() as Promise<{
id?: string | number;
status: string;
fee_details?: Array<{ amount: number }>;
card?: {
last_four_digits?: string;
payment_method?: { name?: string };
cardholder?: { identification?: { type?: string } };
};
}>;
});
// Actualizar registro local
savedPayment.externalId = mpPayment.id?.toString();
savedPayment.externalId = mpPayment.id?.toString() ?? null;
savedPayment.externalStatus = mpPayment.status;
savedPayment.status = this.mapMPStatus(mpPayment.status);
savedPayment.providerResponse = mpPayment;
savedPayment.processedAt = new Date();
if (mpPayment.fee_details?.length > 0) {
if (mpPayment.fee_details && mpPayment.fee_details.length > 0) {
const totalFee = mpPayment.fee_details.reduce(
(sum: number, fee: any) => sum + fee.amount,
(sum: number, fee) => sum + fee.amount,
0
);
savedPayment.feeAmount = totalFee;
@ -195,9 +204,9 @@ export class MercadoPagoService {
}
if (mpPayment.card) {
savedPayment.cardLastFour = mpPayment.card.last_four_digits;
savedPayment.cardBrand = mpPayment.card.payment_method?.name;
savedPayment.cardType = mpPayment.card.cardholder?.identification?.type;
savedPayment.cardLastFour = mpPayment.card.last_four_digits ?? null;
savedPayment.cardBrand = mpPayment.card.payment_method?.name ?? null;
savedPayment.cardType = mpPayment.card.cardholder?.identification?.type ?? null;
}
return this.paymentRepository.save(savedPayment);
@ -249,7 +258,7 @@ export class MercadoPagoService {
});
if (response.ok) {
const mpPayment = await response.json();
const mpPayment = await response.json() as { status: string };
payment.externalStatus = mpPayment.status;
payment.status = this.mapMPStatus(mpPayment.status);
payment.providerResponse = mpPayment;
@ -304,16 +313,16 @@ export class MercadoPagoService {
);
if (!response.ok) {
const error = await response.json();
const error = await response.json() as { message?: string };
throw new MercadoPagoError(error.message || 'Refund failed', response.status, error);
}
return response.json();
return response.json() as Promise<Record<string, any>>;
});
// Actualizar pago
payment.refundedAmount = Number(payment.refundedAmount || 0) + refundAmount;
payment.refundReason = dto.reason;
payment.refundReason = dto.reason ?? null;
payment.refundedAt = new Date();
if (payment.refundedAmount >= Number(payment.amount)) {
@ -370,7 +379,7 @@ export class MercadoPagoService {
});
if (!response.ok) {
const error = await response.json();
const error = await response.json() as { message?: string };
throw new MercadoPagoError(
error.message || 'Failed to create payment link',
response.status,
@ -378,7 +387,7 @@ export class MercadoPagoService {
);
}
return response.json();
return response.json() as Promise<{ init_point: string; id: string }>;
});
return {
@ -465,10 +474,17 @@ export class MercadoPagoService {
if (!response.ok) return;
const mpPayment = await response.json();
const mpPayment = await response.json() as {
status: string;
external_reference?: string;
card?: {
last_four_digits?: string;
payment_method?: { name?: string };
};
};
// Buscar pago local por external_reference o external_id
let payment = await this.paymentRepository.findOne({
const payment = await this.paymentRepository.findOne({
where: [
{ externalId: mpPaymentId.toString(), tenantId },
{ id: mpPayment.external_reference, tenantId },
@ -483,8 +499,8 @@ export class MercadoPagoService {
payment.processedAt = new Date();
if (mpPayment.card) {
payment.cardLastFour = mpPayment.card.last_four_digits;
payment.cardBrand = mpPayment.card.payment_method?.name;
payment.cardLastFour = mpPayment.card.last_four_digits ?? null;
payment.cardBrand = mpPayment.card.payment_method?.name ?? null;
}
await this.paymentRepository.save(payment);
@ -494,7 +510,7 @@ export class MercadoPagoService {
/**
* Procesar webhook de reembolso
*/
private async handleRefundWebhook(tenantId: string, refundId: string): Promise<void> {
private async handleRefundWebhook(tenantId: string, _refundId: string): Promise<void> {
// Implementación similar a handlePaymentWebhook
}

View File

@ -147,12 +147,12 @@ export class TerminalsService {
case 'mercadopago':
// Check MercadoPago terminal status
break;
case 'stripe':
case 'stripe_terminal':
// Check Stripe terminal status
break;
}
} catch (error: any) {
status = 'offline';
status = 'unhealthy';
message = error.message || 'Failed to connect to terminal';
}
@ -209,14 +209,14 @@ export class TerminalsService {
return {
id: terminal.id,
branchId: terminal.branchId,
terminalProvider: terminal.terminalProvider,
terminalProvider: terminal.terminalProvider as TerminalResponseDto['terminalProvider'],
terminalId: terminal.terminalId,
terminalName: terminal.terminalName,
isPrimary: terminal.isPrimary,
isActive: terminal.isActive,
dailyLimit: terminal.dailyLimit ? Number(terminal.dailyLimit) : undefined,
transactionLimit: terminal.transactionLimit ? Number(terminal.transactionLimit) : undefined,
healthStatus: terminal.healthStatus,
dailyLimit: terminal.dailyLimit ? Number(terminal.dailyLimit) : null,
transactionLimit: terminal.transactionLimit ? Number(terminal.transactionLimit) : null,
healthStatus: terminal.healthStatus as TerminalResponseDto['healthStatus'],
lastTransactionAt: terminal.lastTransactionAt,
lastHealthCheckAt: terminal.lastHealthCheckAt,
};

View File

@ -133,7 +133,7 @@ export class TransactionsService {
// Update terminal health
await this.terminalRepository.update(terminal.id, {
healthStatus: 'offline',
healthStatus: 'unhealthy',
lastHealthCheckAt: new Date(),
});
@ -336,18 +336,25 @@ export class TransactionsService {
const byStatus: Record<PaymentStatus, number> = {
pending: 0,
processing: 0,
approved: 0,
authorized: 0,
in_process: 0,
rejected: 0,
refunded: 0,
partially_refunded: 0,
cancelled: 0,
charged_back: 0,
completed: 0,
failed: 0,
refunded: 0,
cancelled: 0,
};
const byProvider: Record<string, { count: number; amount: number }> = {};
const byPaymentMethod: Record<PaymentMethod, number> = {
card: 0,
contactless: 0,
qr: 0,
link: 0,
cash: 0,
bank_transfer: 0,
};
let totalAmount = 0;

View File

@ -0,0 +1,51 @@
/**
* Custom Error Classes
*/
export class AppError extends Error {
constructor(
message: string,
public statusCode: number = 500,
public code?: string
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(message: string = 'Resource not found') {
super(message, 404, 'NOT_FOUND');
}
}
export class ValidationError extends AppError {
constructor(message: string = 'Validation failed') {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class UnauthorizedError extends AppError {
constructor(message: string = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
}
}
export class ForbiddenError extends AppError {
constructor(message: string = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class ConflictError extends AppError {
constructor(message: string = 'Conflict') {
super(message, 409, 'CONFLICT');
}
}
export class InternalError extends AppError {
constructor(message: string = 'Internal server error') {
super(message, 500, 'INTERNAL_ERROR');
}
}

View File

@ -1,6 +1,6 @@
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../errors/index.js';
import { PaginationMeta } from '../types/index.js';
import { query, getClient, PoolClient } from '../../config/database';
import { NotFoundError, ValidationError } from '../errors';
import { PaginationMeta } from '../types';
/**
* Resultado paginado genérico

View File

@ -46,3 +46,23 @@ export interface TokenPair {
accessToken: string;
refreshToken: string;
}
/**
* Pagination metadata
*/
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
/**
* Paginated response wrapper
*/
export interface PaginatedResponse<T> {
data: T[];
meta: PaginationMeta;
}

View File

@ -0,0 +1,116 @@
/**
* Circuit Breaker Pattern Implementation
* Prevents cascade failures by monitoring failed requests
*/
export interface CircuitBreakerOptions {
failureThreshold: number; // Number of failures before opening circuit
halfOpenRequests: number; // Number of requests to allow in half-open state
resetTimeout: number; // Time in ms to wait before attempting half-open
}
type CircuitState = 'closed' | 'open' | 'half-open';
export class CircuitBreaker {
private state: CircuitState = 'closed';
private failureCount: number = 0;
private successCount: number = 0;
private lastFailureTime: number = 0;
private halfOpenSuccesses: number = 0;
constructor(
private name: string,
private options: CircuitBreakerOptions
) {}
/**
* Execute a function through the circuit breaker
*/
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.isOpen()) {
throw new Error(`Circuit breaker ${this.name} is open`);
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
/**
* Check if circuit is open
*/
private isOpen(): boolean {
if (this.state === 'open') {
// Check if enough time has passed to try half-open
if (Date.now() - this.lastFailureTime >= this.options.resetTimeout) {
this.state = 'half-open';
this.halfOpenSuccesses = 0;
return false;
}
return true;
}
return false;
}
/**
* Handle successful execution
*/
private onSuccess(): void {
if (this.state === 'half-open') {
this.halfOpenSuccesses++;
if (this.halfOpenSuccesses >= this.options.halfOpenRequests) {
this.reset();
}
} else {
this.failureCount = 0;
}
}
/**
* Handle failed execution
*/
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.state === 'half-open' || this.failureCount >= this.options.failureThreshold) {
this.state = 'open';
}
}
/**
* Reset circuit breaker to closed state
*/
private reset(): void {
this.state = 'closed';
this.failureCount = 0;
this.halfOpenSuccesses = 0;
}
/**
* Get current state
*/
getState(): CircuitState {
return this.state;
}
/**
* Force open the circuit
*/
trip(): void {
this.state = 'open';
this.lastFailureTime = Date.now();
}
/**
* Force close the circuit
*/
close(): void {
this.reset();
}
}