Migración desde michangarrito/backend - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:12:07 -06:00
parent bad656c8c7
commit 59e6ea4ab6
116 changed files with 23107 additions and 0 deletions

41
.dockerignore Normal file
View File

@ -0,0 +1,41 @@
# Dependencies
node_modules
npm-debug.log
yarn-error.log
# Build output
dist
# IDE
.idea
.vscode
*.swp
*.swo
# Testing
coverage
.nyc_output
# Environment
.env
.env.*
!.env.example
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Documentation
README.md
docs
# Tests
test
*.spec.ts
*.test.ts
jest.config.js

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
.env

89
Dockerfile Normal file
View File

@ -0,0 +1,89 @@
# =============================================================================
# MiChangarrito - Backend Dockerfile
# =============================================================================
# Multi-stage build for NestJS application
# Puerto: 3141
# =============================================================================
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build application
RUN npm run build
# Production stage
FROM node:20-alpine AS production
# Labels
LABEL maintainer="ISEM"
LABEL description="MiChangarrito Backend API"
LABEL version="1.0.0"
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production && npm cache clean --force
# Copy built application
COPY --from=builder /app/dist ./dist
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001 -G nodejs
# Set ownership
RUN chown -R nestjs:nodejs /app
USER nestjs
# Environment
ENV NODE_ENV=production
ENV PORT=3141
# Expose port
EXPOSE 3141
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3141/api/v1/health || exit 1
# Start application
CMD ["node", "dist/main"]
# Development stage
FROM node:20-alpine AS development
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies
RUN npm ci
# Copy source code
COPY . .
# Environment
ENV NODE_ENV=development
ENV PORT=3141
# Expose port
EXPOSE 3141
# Start in development mode
CMD ["npm", "run", "start:dev"]

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11845
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

91
package.json Normal file
View File

@ -0,0 +1,91 @@
{
"name": "michangarrito-backend",
"version": "1.0.0",
"description": "MiChangarrito - POS inteligente para micro-negocios con WhatsApp y LLM",
"author": "ISEM",
"private": true,
"license": "MIT",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "typeorm-ts-node-commonjs",
"migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/database/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts"
},
"dependencies": {
"@nestjs/common": "^11.1.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^11.1.0",
"@nestjs/jwt": "^11.0.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.0",
"@nestjs/swagger": "^8.1.0",
"@nestjs/typeorm": "^11.0.0",
"@react-native-community/netinfo": "^11.4.1",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"helmet": "^8.0.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"stripe": "^20.1.1",
"typeorm": "^0.3.22",
"uuid": "^11.0.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.1.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.8",
"@types/passport-jwt": "^4.0.0",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
"prettier": "^3.2.4",
"source-map-support": "^0.5.21",
"supertest": "^6.3.4",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

69
src/app.module.ts Normal file
View File

@ -0,0 +1,69 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './modules/auth/auth.module';
import { ProductsModule } from './modules/products/products.module';
import { CategoriesModule } from './modules/categories/categories.module';
import { SalesModule } from './modules/sales/sales.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { CustomersModule } from './modules/customers/customers.module';
import { InventoryModule } from './modules/inventory/inventory.module';
import { OrdersModule } from './modules/orders/orders.module';
import { SubscriptionsModule } from './modules/subscriptions/subscriptions.module';
import { MessagingModule } from './modules/messaging/messaging.module';
import { BillingModule } from './modules/billing/billing.module';
import { IntegrationsModule } from './modules/integrations/integrations.module';
import { ReferralsModule } from './modules/referrals/referrals.module';
import { CodiSpeiModule } from './modules/codi-spei/codi-spei.module';
import { WidgetsModule } from './modules/widgets/widgets.module';
import { InvoicesModule } from './modules/invoices/invoices.module';
import { MarketplaceModule } from './modules/marketplace/marketplace.module';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
// Database
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('DB_HOST', 'localhost'),
port: configService.get('DB_PORT', 5432),
username: configService.get('DB_USERNAME', 'michangarrito_dev'),
password: configService.get('DB_PASSWORD', 'MCh_dev_2025_secure'),
database: configService.get('DB_DATABASE', 'michangarrito_dev'),
schema: configService.get('DB_SCHEMA', 'public'),
autoLoadEntities: true,
synchronize: false, // Disabled - using manual SQL schemas
logging: configService.get('NODE_ENV') === 'development',
ssl: configService.get('DB_SSL') === 'true' ? { rejectUnauthorized: false } : false,
}),
}),
// Feature Modules
AuthModule,
ProductsModule,
CategoriesModule,
SalesModule,
PaymentsModule,
CustomersModule,
InventoryModule,
OrdersModule,
SubscriptionsModule,
MessagingModule,
BillingModule,
IntegrationsModule,
ReferralsModule,
CodiSpeiModule,
WidgetsModule,
InvoicesModule,
MarketplaceModule,
],
})
export class AppModule {}

80
src/main.ts Normal file
View File

@ -0,0 +1,80 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Security
app.use(helmet());
// CORS
app.enableCors({
origin: configService.get('CORS_ORIGIN', 'http://localhost:3140'),
credentials: true,
});
// Global prefix (without versioning - controllers define their own version)
app.setGlobalPrefix('api');
// Validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Swagger Documentation
const config = new DocumentBuilder()
.setTitle('MiChangarrito API')
.setDescription(
'POS inteligente para micro-negocios con integración WhatsApp y LLM. Target: Changarros, tienditas, fondas.',
)
.setVersion('1.0')
.addBearerAuth()
.addTag('auth', 'Autenticación y registro')
.addTag('products', 'Gestión de productos')
.addTag('categories', 'Categorías de productos')
.addTag('sales', 'Ventas y tickets')
.addTag('inventory', 'Control de inventario')
.addTag('customers', 'Clientes y fiados')
.addTag('orders', 'Pedidos por WhatsApp')
.addTag('payments', 'Métodos de pago')
.addTag('subscriptions', 'Planes y tokens')
.addTag('messaging', 'WhatsApp y notificaciones')
.addTag('reports', 'Reportes y analytics')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
},
});
const port = configService.get('PORT', 3000);
await app.listen(port);
console.log(`
MICHANGARRITO API
POS Inteligente para Micro-Negocios
Status: Running
Port: ${port}
Environment: ${configService.get('NODE_ENV', 'development')}
Docs: http://localhost:${port}/docs ║
`);
}
bootstrap();

View File

@ -0,0 +1,110 @@
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
UseGuards,
Request,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, RefreshTokenDto } from './dto/register.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@ApiTags('auth')
@Controller('v1/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@ApiOperation({
summary: 'Registrar nuevo negocio',
description: 'Crea un nuevo tenant y usuario con periodo de prueba de 14 días',
})
@ApiResponse({
status: 201,
description: 'Registro exitoso',
schema: {
properties: {
accessToken: { type: 'string' },
refreshToken: { type: 'string' },
user: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
isOwner: { type: 'boolean' },
},
},
tenant: {
type: 'object',
properties: {
id: { type: 'string' },
businessName: { type: 'string' },
plan: { type: 'string' },
subscriptionStatus: { type: 'string' },
trialEndsAt: { type: 'string', format: 'date-time' },
},
},
},
},
})
@ApiResponse({ status: 409, description: 'Teléfono ya registrado' })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Iniciar sesión',
description: 'Autenticación con teléfono y PIN',
})
@ApiResponse({
status: 200,
description: 'Login exitoso',
})
@ApiResponse({ status: 401, description: 'Credenciales inválidas' })
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Refrescar token',
description: 'Obtiene un nuevo access token usando el refresh token',
})
@ApiResponse({
status: 200,
description: 'Token refrescado exitosamente',
})
@ApiResponse({ status: 401, description: 'Token inválido o expirado' })
async refreshToken(@Body() dto: RefreshTokenDto) {
return this.authService.refreshToken(dto.refreshToken);
}
@Post('change-pin')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Cambiar PIN',
description: 'Cambiar el PIN de acceso',
})
@ApiResponse({ status: 200, description: 'PIN cambiado exitosamente' })
@ApiResponse({ status: 401, description: 'PIN actual incorrecto' })
async changePin(
@Request() req: { user: { sub: string } },
@Body() body: { currentPin: string; newPin: string },
) {
await this.authService.changePin(req.user.sub, body.currentPin, body.newPin);
return { message: 'PIN cambiado exitosamente' };
}
}

View File

@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { Tenant } from './entities/tenant.entity';
import { User } from './entities/user.entity';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@Module({
imports: [
TypeOrmModule.forFeature([Tenant, User]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: {
expiresIn: configService.get('JWT_EXPIRES_IN', '24h'),
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard],
exports: [AuthService, JwtAuthGuard, TypeOrmModule],
})
export class AuthModule {}

View File

@ -0,0 +1,252 @@
import {
Injectable,
ConflictException,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { Tenant } from './entities/tenant.entity';
import { User } from './entities/user.entity';
import { RegisterDto, LoginDto } from './dto/register.dto';
export interface TokenPayload {
sub: string;
tenantId: string;
phone: string;
role: string;
}
export interface AuthResponse {
accessToken: string;
refreshToken: string;
user: {
id: string;
name: string;
role: string;
phone: string;
};
tenant: {
id: string;
name: string;
slug: string;
businessType: string;
subscriptionStatus: string;
};
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(Tenant)
private readonly tenantRepository: Repository<Tenant>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
private generateSlug(name: string): string {
return name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.substring(0, 45) + '-' + Date.now().toString(36).slice(-4);
}
async register(dto: RegisterDto): Promise<AuthResponse> {
// Check if phone already registered
const existingTenant = await this.tenantRepository.findOne({
where: { phone: dto.phone },
});
if (existingTenant) {
throw new ConflictException('Este teléfono ya está registrado');
}
// Hash PIN
const pinHash = await bcrypt.hash(dto.pin, 10);
// Generate unique slug
const slug = this.generateSlug(dto.name);
// Create tenant
const tenant = this.tenantRepository.create({
name: dto.name,
slug,
businessType: dto.businessType,
phone: dto.phone,
email: dto.email,
address: dto.address,
city: dto.city,
whatsappNumber: dto.whatsapp || dto.phone,
subscriptionStatus: 'trial',
status: 'active',
});
const savedTenant = await this.tenantRepository.save(tenant);
// Create user (owner)
const user = this.userRepository.create({
tenantId: savedTenant.id,
phone: dto.phone,
name: dto.ownerName,
pinHash,
role: 'owner',
status: 'active',
});
const savedUser = await this.userRepository.save(user);
// Generate tokens
return this.generateTokens(savedUser, savedTenant);
}
async login(dto: LoginDto): Promise<AuthResponse> {
// Find user by phone (users table has phone)
const user = await this.userRepository.findOne({
where: { phone: dto.phone },
});
if (!user) {
throw new UnauthorizedException('Teléfono o PIN incorrectos');
}
// Find tenant
const tenant = await this.tenantRepository.findOne({
where: { id: user.tenantId },
});
if (!tenant) {
throw new UnauthorizedException('Teléfono o PIN incorrectos');
}
// Check subscription status
if (tenant.subscriptionStatus === 'cancelled') {
throw new UnauthorizedException('Tu suscripción ha sido cancelada');
}
if (tenant.subscriptionStatus === 'suspended' || tenant.status === 'suspended') {
throw new UnauthorizedException('Tu cuenta está suspendida. Contacta soporte.');
}
// Check user status
if (user.status !== 'active') {
throw new UnauthorizedException('Tu cuenta está inactiva');
}
// Check if locked
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new UnauthorizedException('Cuenta bloqueada temporalmente. Intenta más tarde.');
}
// Verify PIN
const isValidPin = await bcrypt.compare(dto.pin, user.pinHash);
if (!isValidPin) {
// Increment failed attempts
user.failedAttempts = (user.failedAttempts || 0) + 1;
if (user.failedAttempts >= 5) {
user.lockedUntil = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
}
await this.userRepository.save(user);
throw new UnauthorizedException('Teléfono o PIN incorrectos');
}
// Reset failed attempts and update last login
user.failedAttempts = 0;
user.lockedUntil = null;
user.lastLoginAt = new Date();
await this.userRepository.save(user);
// Generate tokens
return this.generateTokens(user, tenant);
}
async refreshToken(refreshToken: string): Promise<AuthResponse> {
try {
const payload = this.jwtService.verify<TokenPayload>(refreshToken, {
secret: this.configService.get('JWT_SECRET'),
});
const user = await this.userRepository.findOne({
where: { id: payload.sub },
});
if (!user) {
throw new UnauthorizedException('Token inválido');
}
const tenant = await this.tenantRepository.findOne({
where: { id: user.tenantId },
});
if (!tenant) {
throw new UnauthorizedException('Token inválido');
}
return this.generateTokens(user, tenant);
} catch {
throw new UnauthorizedException('Token inválido o expirado');
}
}
async changePin(userId: string, currentPin: string, newPin: string): Promise<void> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new BadRequestException('Usuario no encontrado');
}
const isValidPin = await bcrypt.compare(currentPin, user.pinHash);
if (!isValidPin) {
throw new UnauthorizedException('PIN actual incorrecto');
}
user.pinHash = await bcrypt.hash(newPin, 10);
await this.userRepository.save(user);
}
private generateTokens(user: User, tenant: Tenant): AuthResponse {
const payload: TokenPayload = {
sub: user.id,
tenantId: tenant.id,
phone: user.phone,
role: user.role,
};
const accessToken = this.jwtService.sign(payload, {
expiresIn: this.configService.get('JWT_EXPIRES_IN', '24h'),
});
const refreshToken = this.jwtService.sign(payload, {
expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN', '7d'),
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
name: user.name,
role: user.role,
phone: user.phone,
},
tenant: {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
businessType: tenant.businessType,
subscriptionStatus: tenant.subscriptionStatus,
},
};
}
}

View File

@ -0,0 +1,144 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsNotEmpty,
MinLength,
MaxLength,
IsOptional,
IsEmail,
Matches,
} from 'class-validator';
export class RegisterDto {
@ApiProperty({
description: 'Nombre del negocio',
example: 'Tacos El Güero',
minLength: 2,
maxLength: 100,
})
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(100)
name: string;
@ApiProperty({
description: 'Nombre del propietario',
example: 'Juan Pérez',
minLength: 2,
maxLength: 100,
})
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(100)
ownerName: string;
@ApiProperty({
description: 'Tipo de negocio',
example: 'tiendita',
enum: ['tiendita', 'fonda', 'taqueria', 'abarrotes', 'tortilleria', 'otro'],
})
@IsString()
@IsNotEmpty()
@MaxLength(50)
businessType: string;
@ApiProperty({
description: 'Teléfono (10 dígitos)',
example: '5512345678',
})
@IsString()
@IsNotEmpty()
@Matches(/^[0-9]{10}$/, {
message: 'El teléfono debe tener exactamente 10 dígitos',
})
phone: string;
@ApiProperty({
description: 'PIN de acceso rápido (4-6 dígitos)',
example: '1234',
minLength: 4,
maxLength: 6,
})
@IsString()
@IsNotEmpty()
@Matches(/^[0-9]{4,6}$/, {
message: 'El PIN debe tener entre 4 y 6 dígitos',
})
pin: string;
@ApiProperty({
description: 'Número de WhatsApp (opcional)',
example: '5512345678',
required: false,
})
@IsOptional()
@IsString()
@Matches(/^[0-9]{10}$/, {
message: 'El WhatsApp debe tener exactamente 10 dígitos',
})
whatsapp?: string;
@ApiProperty({
description: 'Email (opcional)',
example: 'juan@ejemplo.com',
required: false,
})
@IsOptional()
@IsEmail({}, { message: 'Email inválido' })
email?: string;
@ApiProperty({
description: 'Dirección del negocio (opcional)',
example: 'Calle Principal #123, Colonia Centro',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(500)
address?: string;
@ApiProperty({
description: 'Ciudad (opcional)',
example: 'Ciudad de México',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(50)
city?: string;
}
export class LoginDto {
@ApiProperty({
description: 'Teléfono registrado',
example: '5512345678',
})
@IsString()
@IsNotEmpty()
@Matches(/^[0-9]{10}$/, {
message: 'El teléfono debe tener exactamente 10 dígitos',
})
phone: string;
@ApiProperty({
description: 'PIN de acceso',
example: '1234',
})
@IsString()
@IsNotEmpty()
@Matches(/^[0-9]{4,6}$/, {
message: 'El PIN debe tener entre 4 y 6 dígitos',
})
pin: string;
}
export class RefreshTokenDto {
@ApiProperty({
description: 'Token de refresco',
})
@IsString()
@IsNotEmpty()
refreshToken: string;
}

View File

@ -0,0 +1,113 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
export enum SubscriptionStatus {
TRIAL = 'trial',
ACTIVE = 'active',
SUSPENDED = 'suspended',
CANCELLED = 'cancelled',
}
export enum TenantStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
SUSPENDED = 'suspended',
}
@Entity({ schema: 'public', name: 'tenants' })
export class Tenant {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 100 })
name: string;
@Column({ length: 50, unique: true })
slug: string;
@Column({ name: 'business_type', length: 50 })
businessType: string;
@Column({ length: 20 })
phone: string;
@Column({ length: 100, nullable: true })
email: string;
@Column({ type: 'text', nullable: true })
address: string;
@Column({ length: 50, nullable: true })
city: string;
@Column({ length: 50, nullable: true })
state: string;
@Column({ name: 'zip_code', length: 10, nullable: true })
zipCode: string;
@Column({ length: 50, default: 'America/Mexico_City' })
timezone: string;
@Column({ length: 3, default: 'MXN' })
currency: string;
@Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.0 })
taxRate: number;
@Column({ name: 'tax_included', default: true })
taxIncluded: boolean;
@Column({ name: 'whatsapp_number', length: 20, nullable: true })
whatsappNumber: string;
@Column({ name: 'whatsapp_verified', default: false })
whatsappVerified: boolean;
@Column({ name: 'uses_platform_number', default: true })
usesPlatformNumber: boolean;
@Column({ name: 'preferred_llm_provider', length: 20, default: 'openai' })
preferredLlmProvider: string;
@Column({ name: 'preferred_payment_provider', length: 20, default: 'stripe' })
preferredPaymentProvider: string;
@Column({ name: 'current_plan_id', type: 'uuid', nullable: true })
currentPlanId: string;
@Column({
name: 'subscription_status',
length: 20,
default: 'trial',
})
subscriptionStatus: string;
@Column({
length: 20,
default: 'active',
})
status: string;
@Column({ name: 'onboarding_completed', default: false })
onboardingCompleted: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => User, (user) => user.tenant)
users: User[];
}

View File

@ -0,0 +1,78 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Tenant } from './tenant.entity';
export enum UserRole {
OWNER = 'owner',
ADMIN = 'admin',
EMPLOYEE = 'employee',
}
export enum UserStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
SUSPENDED = 'suspended',
}
@Entity({ schema: 'auth', name: 'users' })
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ length: 20 })
phone: string;
@Column({ length: 100, nullable: true })
email: string;
@Column({ length: 100 })
name: string;
@Column({ name: 'pin_hash', length: 255, nullable: true })
pinHash: string;
@Column({ name: 'biometric_enabled', default: false })
biometricEnabled: boolean;
@Column({ name: 'biometric_key', type: 'text', nullable: true })
biometricKey: string;
@Column({ length: 20, default: 'owner' })
role: string;
@Column({ type: 'jsonb', default: {} })
permissions: Record<string, unknown>;
@Column({ length: 20, default: 'active' })
status: string;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt: Date;
@Column({ name: 'failed_attempts', default: 0 })
failedAttempts: number;
@Column({ name: 'locked_until', type: 'timestamptz', nullable: true })
lockedUntil: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@ManyToOne(() => Tenant, (tenant) => tenant.users)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
}

View File

@ -0,0 +1,16 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest<TUser>(err: Error | null, user: TUser): TUser {
if (err || !user) {
throw err || new UnauthorizedException('Token inválido o expirado');
}
return user;
}
}

View File

@ -0,0 +1,39 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
import { TokenPayload } from '../auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(payload: TokenPayload) {
const user = await this.userRepository.findOne({
where: { id: payload.sub },
});
if (!user) {
throw new UnauthorizedException('Usuario no encontrado');
}
return {
sub: payload.sub,
tenantId: payload.tenantId,
phone: payload.phone,
};
}
}

View File

@ -0,0 +1,86 @@
import {
Controller,
Get,
Post,
Body,
UseGuards,
Request,
Query,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { BillingService } from './billing.service';
@Controller('billing')
@UseGuards(JwtAuthGuard)
export class BillingController {
constructor(private readonly billingService: BillingService) {}
@Get('plans')
async getPlans() {
return this.billingService.getPlans();
}
@Get('token-packages')
async getTokenPackages() {
return this.billingService.getTokenPackages();
}
@Get('summary')
async getBillingSummary(@Request() req: any) {
return this.billingService.getBillingSummary(req.user.tenantId);
}
@Get('token-balance')
async getTokenBalance(@Request() req: any) {
const balance = await this.billingService.getTokenBalance(req.user.tenantId);
return balance || { availableTokens: 0, usedTokens: 0, totalTokens: 0 };
}
@Get('token-usage')
async getTokenUsage(
@Request() req: any,
@Query('limit') limit?: string
) {
return this.billingService.getTokenUsageHistory(
req.user.tenantId,
limit ? parseInt(limit, 10) : 50
);
}
@Post('checkout/subscription')
async createSubscriptionCheckout(
@Request() req: any,
@Body() body: { planCode: string; successUrl: string; cancelUrl: string }
) {
return this.billingService.createSubscriptionCheckout(
req.user.tenantId,
body.planCode,
body.successUrl,
body.cancelUrl
);
}
@Post('checkout/tokens')
async createTokenCheckout(
@Request() req: any,
@Body() body: { packageCode: string; successUrl: string; cancelUrl: string }
) {
return this.billingService.createTokenPurchaseCheckout(
req.user.tenantId,
body.packageCode,
body.successUrl,
body.cancelUrl
);
}
@Post('portal')
async createPortalSession(
@Request() req: any,
@Body() body: { returnUrl: string }
) {
return this.billingService.createPortalSession(
req.user.tenantId,
body.returnUrl
);
}
}

View File

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingService } from './billing.service';
import { BillingController } from './billing.controller';
import { StripeService } from './stripe.service';
import { WebhooksController } from './webhooks.controller';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Plan } from '../subscriptions/entities/plan.entity';
import { TokenBalance } from '../subscriptions/entities/token-balance.entity';
import { TokenUsage } from '../subscriptions/entities/token-usage.entity';
@Module({
imports: [
ConfigModule,
TypeOrmModule.forFeature([
Subscription,
Plan,
TokenBalance,
TokenUsage,
]),
],
controllers: [BillingController, WebhooksController],
providers: [BillingService, StripeService],
exports: [BillingService, StripeService],
})
export class BillingModule {}

View File

@ -0,0 +1,299 @@
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { StripeService } from './stripe.service';
import { Subscription, SubscriptionStatus } from '../subscriptions/entities/subscription.entity';
import { Plan } from '../subscriptions/entities/plan.entity';
import { TokenBalance } from '../subscriptions/entities/token-balance.entity';
import { TokenUsage } from '../subscriptions/entities/token-usage.entity';
export interface TokenPackage {
code: string;
name: string;
tokens: number;
priceMxn: number;
stripePriceId?: string;
}
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
// Token packages configuration
private readonly tokenPackages: TokenPackage[] = [
{ code: 'tokens_1000', name: '1,000 Tokens', tokens: 1000, priceMxn: 29 },
{ code: 'tokens_3000', name: '3,000 Tokens', tokens: 3000, priceMxn: 69 },
{ code: 'tokens_8000', name: '8,000 Tokens', tokens: 8000, priceMxn: 149 },
{ code: 'tokens_20000', name: '20,000 Tokens', tokens: 20000, priceMxn: 299 },
];
constructor(
private stripeService: StripeService,
@InjectRepository(Subscription)
private subscriptionRepo: Repository<Subscription>,
@InjectRepository(Plan)
private planRepo: Repository<Plan>,
@InjectRepository(TokenBalance)
private tokenBalanceRepo: Repository<TokenBalance>,
@InjectRepository(TokenUsage)
private tokenUsageRepo: Repository<TokenUsage>,
) {}
// Get available plans
async getPlans(): Promise<Plan[]> {
return this.planRepo.find({
where: { status: 'active' },
order: { priceMonthly: 'ASC' },
});
}
// Get token packages
getTokenPackages(): TokenPackage[] {
return this.tokenPackages;
}
// Create checkout session for subscription
async createSubscriptionCheckout(
tenantId: string,
planCode: string,
successUrl: string,
cancelUrl: string
): Promise<{ checkoutUrl: string }> {
const plan = await this.planRepo.findOne({ where: { code: planCode, status: 'active' } });
if (!plan || !plan.stripePriceIdMonthly) {
throw new NotFoundException('Plan no encontrado');
}
const subscription = await this.subscriptionRepo.findOne({ where: { tenantId } });
if (!subscription?.stripeCustomerId) {
throw new BadRequestException('Cliente de Stripe no configurado');
}
const session = await this.stripeService.createCheckoutSession({
customerId: subscription.stripeCustomerId,
priceId: plan.stripePriceIdMonthly,
mode: 'subscription',
successUrl,
cancelUrl,
metadata: { tenantId, planCode },
});
return { checkoutUrl: session.url! };
}
// Create checkout session for token purchase
async createTokenPurchaseCheckout(
tenantId: string,
packageCode: string,
successUrl: string,
cancelUrl: string
): Promise<{ checkoutUrl: string }> {
const tokenPackage = this.tokenPackages.find((p) => p.code === packageCode);
if (!tokenPackage) {
throw new NotFoundException('Paquete de tokens no encontrado');
}
const subscription = await this.subscriptionRepo.findOne({ where: { tenantId } });
if (!subscription?.stripeCustomerId) {
throw new BadRequestException('Cliente de Stripe no configurado');
}
// For token purchases, we'll create a payment intent
const paymentIntent = await this.stripeService.createPaymentIntent({
amount: tokenPackage.priceMxn * 100, // Convert to cents
customerId: subscription.stripeCustomerId,
metadata: {
tenantId,
packageCode,
tokens: tokenPackage.tokens.toString(),
},
});
// In production, you'd return a checkout session URL
// For now, return the client secret for custom payment form
return { checkoutUrl: paymentIntent.client_secret! };
}
// Create customer portal session
async createPortalSession(
tenantId: string,
returnUrl: string
): Promise<{ portalUrl: string }> {
const subscription = await this.subscriptionRepo.findOne({ where: { tenantId } });
if (!subscription?.stripeCustomerId) {
throw new BadRequestException('Cliente de Stripe no configurado');
}
const session = await this.stripeService.createPortalSession({
customerId: subscription.stripeCustomerId,
returnUrl,
});
return { portalUrl: session.url };
}
// Handle successful subscription payment
async handleSubscriptionCreated(
stripeSubscriptionId: string,
stripeCustomerId: string,
stripePriceId: string
): Promise<void> {
// Try to find plan by monthly or yearly price ID
let plan = await this.planRepo.findOne({ where: { stripePriceIdMonthly: stripePriceId } });
if (!plan) {
plan = await this.planRepo.findOne({ where: { stripePriceIdYearly: stripePriceId } });
}
if (!plan) {
this.logger.warn(`Plan not found for price: ${stripePriceId}`);
return;
}
// Find subscription by customer ID
const subscription = await this.subscriptionRepo.findOne({
where: { stripeCustomerId },
});
if (subscription) {
subscription.planId = plan.id;
subscription.stripeSubscriptionId = stripeSubscriptionId;
subscription.status = SubscriptionStatus.ACTIVE;
subscription.currentPeriodStart = new Date();
subscription.currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // +30 days
await this.subscriptionRepo.save(subscription);
// Add plan tokens to balance
if (plan.includedTokens > 0) {
await this.addTokensToBalance(subscription.tenantId, plan.includedTokens, 'subscription');
}
this.logger.log(`Subscription activated for tenant: ${subscription.tenantId}`);
}
}
// Handle subscription cancelled
async handleSubscriptionCancelled(stripeSubscriptionId: string): Promise<void> {
const subscription = await this.subscriptionRepo.findOne({
where: { stripeSubscriptionId },
});
if (subscription) {
subscription.status = SubscriptionStatus.CANCELLED;
subscription.cancelledAt = new Date();
await this.subscriptionRepo.save(subscription);
this.logger.log(`Subscription cancelled for tenant: ${subscription.tenantId}`);
}
}
// Handle token purchase completed
async handleTokenPurchase(
tenantId: string,
packageCode: string,
tokens: number
): Promise<void> {
await this.addTokensToBalance(tenantId, tokens, 'purchase');
this.logger.log(`Added ${tokens} tokens to tenant: ${tenantId}`);
}
// Token Management
async getTokenBalance(tenantId: string): Promise<TokenBalance | null> {
return this.tokenBalanceRepo.findOne({ where: { tenantId } });
}
async addTokensToBalance(
tenantId: string,
tokens: number,
source: 'subscription' | 'purchase' | 'bonus'
): Promise<TokenBalance> {
let balance = await this.tokenBalanceRepo.findOne({ where: { tenantId } });
if (!balance) {
balance = this.tokenBalanceRepo.create({
tenantId,
usedTokens: 0,
availableTokens: 0,
});
}
balance.availableTokens += tokens;
return this.tokenBalanceRepo.save(balance);
}
async consumeTokens(
tenantId: string,
tokens: number,
action: string,
description?: string
): Promise<boolean> {
const balance = await this.tokenBalanceRepo.findOne({ where: { tenantId } });
if (!balance || balance.availableTokens < tokens) {
return false;
}
// Update balance
balance.usedTokens += tokens;
balance.availableTokens -= tokens;
await this.tokenBalanceRepo.save(balance);
// Record usage
const usage = this.tokenUsageRepo.create({
tenantId,
tokensUsed: tokens,
action,
description,
});
await this.tokenUsageRepo.save(usage);
return true;
}
async getTokenUsageHistory(
tenantId: string,
limit = 50
): Promise<TokenUsage[]> {
return this.tokenUsageRepo.find({
where: { tenantId },
order: { createdAt: 'DESC' },
take: limit,
});
}
// Get billing summary
async getBillingSummary(tenantId: string): Promise<{
subscription: Subscription | null;
plan: Plan | null;
tokenBalance: TokenBalance | null;
invoices: any[];
}> {
const subscription = await this.subscriptionRepo.findOne({
where: { tenantId },
relations: ['plan'],
});
const tokenBalance = await this.getTokenBalance(tenantId);
let invoices: any[] = [];
if (subscription?.stripeCustomerId) {
try {
invoices = await this.stripeService.listInvoices(subscription.stripeCustomerId, 5);
} catch (error) {
this.logger.warn('Could not fetch invoices from Stripe');
}
}
return {
subscription,
plan: subscription?.plan || null,
tokenBalance,
invoices: invoices.map((inv) => ({
id: inv.id,
amount: inv.amount_paid / 100,
status: inv.status,
date: new Date(inv.created * 1000),
pdfUrl: inv.invoice_pdf,
})),
};
}
}

View File

@ -0,0 +1,223 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
@Injectable()
export class StripeService {
private readonly logger = new Logger(StripeService.name);
private stripe: Stripe;
constructor(private configService: ConfigService) {
const secretKey = this.configService.get<string>('STRIPE_SECRET_KEY');
if (!secretKey) {
this.logger.warn('STRIPE_SECRET_KEY not configured - billing features disabled');
return;
}
this.stripe = new Stripe(secretKey);
}
private ensureStripe(): void {
if (!this.stripe) {
throw new Error('Stripe not configured');
}
}
// Customer Management
async createCustomer(params: {
email?: string;
phone: string;
name: string;
tenantId: string;
}): Promise<Stripe.Customer> {
this.ensureStripe();
return this.stripe.customers.create({
email: params.email,
phone: params.phone,
name: params.name,
metadata: {
tenantId: params.tenantId,
},
});
}
async getCustomer(customerId: string): Promise<Stripe.Customer | null> {
this.ensureStripe();
try {
const customer = await this.stripe.customers.retrieve(customerId);
return customer.deleted ? null : customer as Stripe.Customer;
} catch (error) {
return null;
}
}
// Subscription Management
async createSubscription(params: {
customerId: string;
priceId: string;
trialDays?: number;
}): Promise<Stripe.Subscription> {
this.ensureStripe();
const subscriptionParams: Stripe.SubscriptionCreateParams = {
customer: params.customerId,
items: [{ price: params.priceId }],
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
};
if (params.trialDays) {
subscriptionParams.trial_period_days = params.trialDays;
}
return this.stripe.subscriptions.create(subscriptionParams);
}
async cancelSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
this.ensureStripe();
return this.stripe.subscriptions.cancel(subscriptionId);
}
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription | null> {
this.ensureStripe();
try {
return await this.stripe.subscriptions.retrieve(subscriptionId);
} catch (error) {
return null;
}
}
async updateSubscription(
subscriptionId: string,
params: Stripe.SubscriptionUpdateParams
): Promise<Stripe.Subscription> {
this.ensureStripe();
return this.stripe.subscriptions.update(subscriptionId, params);
}
// Payment Intent (for one-time purchases like token packages)
async createPaymentIntent(params: {
amount: number; // in cents (MXN)
customerId: string;
metadata?: Record<string, string>;
}): Promise<Stripe.PaymentIntent> {
this.ensureStripe();
return this.stripe.paymentIntents.create({
amount: params.amount,
currency: 'mxn',
customer: params.customerId,
metadata: params.metadata || {},
automatic_payment_methods: {
enabled: true,
},
});
}
// Checkout Session (for hosted checkout)
async createCheckoutSession(params: {
customerId: string;
priceId: string;
mode: 'subscription' | 'payment';
successUrl: string;
cancelUrl: string;
metadata?: Record<string, string>;
}): Promise<Stripe.Checkout.Session> {
this.ensureStripe();
return this.stripe.checkout.sessions.create({
customer: params.customerId,
mode: params.mode,
line_items: [{ price: params.priceId, quantity: 1 }],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: params.metadata || {},
locale: 'es',
payment_method_types: params.mode === 'subscription'
? ['card']
: ['card', 'oxxo'],
});
}
// Customer Portal (for managing subscriptions)
async createPortalSession(params: {
customerId: string;
returnUrl: string;
}): Promise<Stripe.BillingPortal.Session> {
this.ensureStripe();
return this.stripe.billingPortal.sessions.create({
customer: params.customerId,
return_url: params.returnUrl,
});
}
// Webhook signature verification
constructWebhookEvent(
payload: string | Buffer,
signature: string,
webhookSecret: string
): Stripe.Event {
this.ensureStripe();
return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
}
// Products and Prices (for initial setup)
async createProduct(params: {
name: string;
description?: string;
metadata?: Record<string, string>;
}): Promise<Stripe.Product> {
this.ensureStripe();
return this.stripe.products.create({
name: params.name,
description: params.description,
metadata: params.metadata || {},
});
}
async createPrice(params: {
productId: string;
unitAmount: number; // in cents
recurring?: { interval: 'month' | 'year' };
metadata?: Record<string, string>;
}): Promise<Stripe.Price> {
this.ensureStripe();
const priceParams: Stripe.PriceCreateParams = {
product: params.productId,
unit_amount: params.unitAmount,
currency: 'mxn',
metadata: params.metadata || {},
};
if (params.recurring) {
priceParams.recurring = params.recurring;
}
return this.stripe.prices.create(priceParams);
}
// List invoices
async listInvoices(customerId: string, limit = 10): Promise<Stripe.Invoice[]> {
this.ensureStripe();
const invoices = await this.stripe.invoices.list({
customer: customerId,
limit,
});
return invoices.data;
}
}

View File

@ -0,0 +1,119 @@
import {
Controller,
Post,
Headers,
RawBodyRequest,
Req,
Logger,
HttpCode,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import Stripe from 'stripe';
import { StripeService } from './stripe.service';
import { BillingService } from './billing.service';
@Controller('webhooks')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
constructor(
private stripeService: StripeService,
private billingService: BillingService,
private configService: ConfigService,
) {}
@Post('stripe')
@HttpCode(200)
async handleStripeWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string,
) {
const webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET');
if (!webhookSecret) {
this.logger.warn('STRIPE_WEBHOOK_SECRET not configured');
return { received: true };
}
let event: Stripe.Event;
try {
event = this.stripeService.constructWebhookEvent(
req.rawBody!,
signature,
webhookSecret,
);
} catch (err: any) {
this.logger.error(`Webhook signature verification failed: ${err.message}`);
return { error: 'Invalid signature' };
}
this.logger.log(`Received Stripe event: ${event.type}`);
try {
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await this.billingService.handleSubscriptionCreated(
subscription.id,
subscription.customer as string,
subscription.items.data[0].price.id,
);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await this.billingService.handleSubscriptionCancelled(subscription.id);
break;
}
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const metadata = paymentIntent.metadata;
// Check if this is a token purchase
if (metadata.packageCode && metadata.tenantId && metadata.tokens) {
await this.billingService.handleTokenPurchase(
metadata.tenantId,
metadata.packageCode,
parseInt(metadata.tokens, 10),
);
}
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
this.logger.log(`Invoice paid: ${invoice.id}`);
// Could trigger email notification here
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
this.logger.warn(`Invoice payment failed: ${invoice.id}`);
// Could trigger email notification or suspend service
break;
}
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
this.logger.log(`Checkout completed: ${session.id}`);
// Additional handling if needed
break;
}
default:
this.logger.debug(`Unhandled event type: ${event.type}`);
}
} catch (error: any) {
this.logger.error(`Error processing webhook: ${error.message}`);
// Still return 200 to prevent Stripe from retrying
}
return { received: true };
}
}

View File

@ -0,0 +1,95 @@
import {
Controller,
Get,
Post,
Put,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { CategoriesService } from './categories.service';
import { CreateCategoryDto, UpdateCategoryDto } from './dto/category.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('categories')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Get()
@ApiOperation({ summary: 'Listar categorías' })
async findAll(
@Request() req: { user: { tenantId: string } },
@Query('includeInactive') includeInactive?: boolean,
) {
return this.categoriesService.findAll(req.user.tenantId, includeInactive);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener categoría por ID' })
@ApiParam({ name: 'id', description: 'ID de la categoría' })
async findOne(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.categoriesService.findOne(req.user.tenantId, id);
}
@Post()
@ApiOperation({ summary: 'Crear categoría' })
@ApiResponse({ status: 201, description: 'Categoría creada' })
async create(
@Request() req: { user: { tenantId: string } },
@Body() dto: CreateCategoryDto,
) {
return this.categoriesService.create(req.user.tenantId, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Actualizar categoría' })
@ApiParam({ name: 'id', description: 'ID de la categoría' })
async update(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCategoryDto,
) {
return this.categoriesService.update(req.user.tenantId, id, dto);
}
@Patch(':id/toggle-active')
@ApiOperation({ summary: 'Activar/desactivar categoría' })
@ApiParam({ name: 'id', description: 'ID de la categoría' })
async toggleActive(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.categoriesService.toggleActive(req.user.tenantId, id);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Eliminar categoría' })
@ApiParam({ name: 'id', description: 'ID de la categoría' })
async delete(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
) {
await this.categoriesService.delete(req.user.tenantId, id);
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
import { Category } from './entities/category.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Category]),
AuthModule,
],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService, TypeOrmModule],
})
export class CategoriesModule {}

View File

@ -0,0 +1,101 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Category } from './entities/category.entity';
import { CreateCategoryDto, UpdateCategoryDto } from './dto/category.dto';
const MAX_CATEGORIES = 20;
@Injectable()
export class CategoriesService {
constructor(
@InjectRepository(Category)
private readonly categoryRepository: Repository<Category>,
) {}
async findAll(tenantId: string, includeInactive = false): Promise<Category[]> {
const where: Record<string, unknown> = { tenantId };
if (!includeInactive) {
where.status = 'active';
}
return this.categoryRepository.find({
where,
order: { sortOrder: 'ASC', name: 'ASC' },
});
}
async findOne(tenantId: string, id: string): Promise<Category> {
const category = await this.categoryRepository.findOne({
where: { id, tenantId },
});
if (!category) {
throw new NotFoundException('Categoría no encontrada');
}
return category;
}
async create(tenantId: string, dto: CreateCategoryDto): Promise<Category> {
// Check limit
const count = await this.categoryRepository.count({ where: { tenantId } });
if (count >= MAX_CATEGORIES) {
throw new BadRequestException(
`Has alcanzado el límite de ${MAX_CATEGORIES} categorías`,
);
}
// Check name uniqueness
const existing = await this.categoryRepository.findOne({
where: { tenantId, name: dto.name },
});
if (existing) {
throw new ConflictException('Ya existe una categoría con ese nombre');
}
const category = this.categoryRepository.create({
...dto,
tenantId,
});
return this.categoryRepository.save(category);
}
async update(tenantId: string, id: string, dto: UpdateCategoryDto): Promise<Category> {
const category = await this.findOne(tenantId, id);
// Check name uniqueness if changed
if (dto.name && dto.name !== category.name) {
const existing = await this.categoryRepository.findOne({
where: { tenantId, name: dto.name },
});
if (existing) {
throw new ConflictException('Ya existe una categoría con ese nombre');
}
}
Object.assign(category, dto);
return this.categoryRepository.save(category);
}
async delete(tenantId: string, id: string): Promise<void> {
const category = await this.findOne(tenantId, id);
await this.categoryRepository.remove(category);
}
async toggleActive(tenantId: string, id: string): Promise<Category> {
const category = await this.findOne(tenantId, id);
category.status = category.status === 'active' ? 'inactive' : 'active';
return this.categoryRepository.save(category);
}
}

View File

@ -0,0 +1,54 @@
import { IsString, IsOptional, IsNumber, MaxLength } from 'class-validator';
export class CreateCategoryDto {
@IsString()
@MaxLength(50)
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
}
export class UpdateCategoryDto {
@IsOptional()
@IsString()
@MaxLength(50)
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsString()
status?: string;
}

View File

@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { Product } from '../../products/entities/product.entity';
@Entity({ schema: 'catalog', name: 'categories' })
export class Category {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ length: 50 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ length: 50, nullable: true })
icon: string;
@Column({ length: 7, nullable: true })
color: string;
@Column({ name: 'sort_order', default: 0 })
sortOrder: number;
@Column({ length: 20, default: 'active' })
status: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => Product, (product) => product.category)
products: Product[];
}

View File

@ -0,0 +1,111 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CodiSpeiService } from './codi-spei.service';
import { GenerateQrDto } from './dto/generate-qr.dto';
@ApiTags('codi-spei')
@Controller('v1')
export class CodiSpeiController {
constructor(private readonly codiSpeiService: CodiSpeiService) {}
// ==================== CODI ====================
@Post('codi/generate-qr')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Generar QR CoDi para cobro' })
generateQr(@Request() req, @Body() dto: GenerateQrDto) {
return this.codiSpeiService.generateQr(req.user.tenantId, dto);
}
@Get('codi/status/:id')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Obtener estado de transaccion CoDi' })
getCodiStatus(@Param('id') id: string) {
return this.codiSpeiService.getCodiStatus(id);
}
@Get('codi/transactions')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Listar transacciones CoDi' })
@ApiQuery({ name: 'limit', required: false })
getCodiTransactions(@Request() req, @Query('limit') limit?: number) {
return this.codiSpeiService.getCodiTransactions(req.user.tenantId, limit);
}
@Post('codi/webhook')
@ApiOperation({ summary: 'Webhook para confirmacion CoDi' })
async codiWebhook(@Body() payload: any) {
await this.codiSpeiService.handleCodiWebhook(payload);
return { success: true };
}
// ==================== SPEI ====================
@Get('spei/clabe')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Obtener CLABE virtual del tenant' })
async getClabe(@Request() req) {
const account = await this.codiSpeiService.getVirtualAccount(req.user.tenantId);
if (!account) {
return { clabe: null, message: 'No tiene CLABE virtual configurada' };
}
return {
clabe: account.clabe,
beneficiaryName: account.beneficiaryName,
status: account.status,
};
}
@Post('spei/create-clabe')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Crear CLABE virtual para el tenant' })
createClabe(@Request() req, @Body() body: { beneficiaryName: string }) {
return this.codiSpeiService.createVirtualAccount(
req.user.tenantId,
body.beneficiaryName,
);
}
@Get('spei/transactions')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Listar transacciones SPEI recibidas' })
@ApiQuery({ name: 'limit', required: false })
getSpeiTransactions(@Request() req, @Query('limit') limit?: number) {
return this.codiSpeiService.getSpeiTransactions(req.user.tenantId, limit);
}
@Post('spei/webhook')
@ApiOperation({ summary: 'Webhook para notificacion SPEI' })
async speiWebhook(@Body() payload: any) {
await this.codiSpeiService.handleSpeiWebhook(payload.clabe, payload);
return { success: true };
}
// ==================== SUMMARY ====================
@Get('payments/summary')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Resumen de pagos CoDi/SPEI del dia' })
@ApiQuery({ name: 'date', required: false })
async getSummary(@Request() req, @Query('date') date?: string) {
const targetDate = date ? new Date(date) : undefined;
return this.codiSpeiService.getSummary(req.user.tenantId, targetDate);
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CodiSpeiController } from './codi-spei.controller';
import { CodiSpeiService } from './codi-spei.service';
import { VirtualAccount } from './entities/virtual-account.entity';
import { CodiTransaction } from './entities/codi-transaction.entity';
import { SpeiTransaction } from './entities/spei-transaction.entity';
@Module({
imports: [
TypeOrmModule.forFeature([VirtualAccount, CodiTransaction, SpeiTransaction]),
],
controllers: [CodiSpeiController],
providers: [CodiSpeiService],
exports: [CodiSpeiService],
})
export class CodiSpeiModule {}

View File

@ -0,0 +1,263 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, LessThan } from 'typeorm';
import { VirtualAccount, VirtualAccountStatus } from './entities/virtual-account.entity';
import { CodiTransaction, CodiTransactionStatus } from './entities/codi-transaction.entity';
import { SpeiTransaction, SpeiTransactionStatus } from './entities/spei-transaction.entity';
import { GenerateQrDto } from './dto/generate-qr.dto';
@Injectable()
export class CodiSpeiService {
constructor(
@InjectRepository(VirtualAccount)
private readonly virtualAccountRepo: Repository<VirtualAccount>,
@InjectRepository(CodiTransaction)
private readonly codiRepo: Repository<CodiTransaction>,
@InjectRepository(SpeiTransaction)
private readonly speiRepo: Repository<SpeiTransaction>,
private readonly dataSource: DataSource,
) {}
// ==================== VIRTUAL ACCOUNTS (CLABE) ====================
async getVirtualAccount(tenantId: string): Promise<VirtualAccount | null> {
return this.virtualAccountRepo.findOne({
where: { tenantId, status: VirtualAccountStatus.ACTIVE },
});
}
async createVirtualAccount(
tenantId: string,
beneficiaryName: string,
provider: string = 'stp',
): Promise<VirtualAccount> {
// Check if already has one
const existing = await this.getVirtualAccount(tenantId);
if (existing) {
return existing;
}
// In production, this would call the provider API to create a CLABE
// For now, generate a mock CLABE
const mockClabe = `646180${Math.floor(Math.random() * 1000000000000).toString().padStart(12, '0')}`;
const account = this.virtualAccountRepo.create({
tenantId,
provider,
clabe: mockClabe,
beneficiaryName,
status: VirtualAccountStatus.ACTIVE,
});
return this.virtualAccountRepo.save(account);
}
// ==================== CODI ====================
async generateQr(tenantId: string, dto: GenerateQrDto): Promise<CodiTransaction> {
// Generate unique reference
const result = await this.dataSource.query(
`SELECT generate_codi_reference($1) as reference`,
[tenantId],
);
const reference = result[0].reference;
// Set expiry (5 minutes)
const expiresAt = new Date();
expiresAt.setMinutes(expiresAt.getMinutes() + 5);
// In production, this would call Banxico/PAC API to generate real CoDi QR
// For now, generate mock QR data
const qrData = JSON.stringify({
type: 'codi',
amount: dto.amount,
reference,
merchant: tenantId,
expires: expiresAt.toISOString(),
});
const transaction = this.codiRepo.create({
tenantId,
saleId: dto.saleId,
qrData,
amount: dto.amount,
reference,
description: dto.description || `Cobro ${reference}`,
status: CodiTransactionStatus.PENDING,
expiresAt,
});
return this.codiRepo.save(transaction);
}
async getCodiStatus(id: string): Promise<CodiTransaction> {
const transaction = await this.codiRepo.findOne({ where: { id } });
if (!transaction) {
throw new NotFoundException('Transaccion CoDi no encontrada');
}
// Check if expired
if (
transaction.status === CodiTransactionStatus.PENDING &&
new Date() > transaction.expiresAt
) {
transaction.status = CodiTransactionStatus.EXPIRED;
await this.codiRepo.save(transaction);
}
return transaction;
}
async confirmCodi(id: string, providerData: any): Promise<CodiTransaction> {
const transaction = await this.getCodiStatus(id);
if (transaction.status !== CodiTransactionStatus.PENDING) {
throw new BadRequestException(`Transaccion no esta pendiente: ${transaction.status}`);
}
transaction.status = CodiTransactionStatus.CONFIRMED;
transaction.confirmedAt = new Date();
transaction.providerResponse = providerData;
return this.codiRepo.save(transaction);
}
async getCodiTransactions(
tenantId: string,
limit = 50,
): Promise<CodiTransaction[]> {
return this.codiRepo.find({
where: { tenantId },
order: { createdAt: 'DESC' },
take: limit,
});
}
// ==================== SPEI ====================
async getSpeiTransactions(
tenantId: string,
limit = 50,
): Promise<SpeiTransaction[]> {
return this.speiRepo.find({
where: { tenantId },
order: { receivedAt: 'DESC' },
take: limit,
});
}
async receiveSpei(
tenantId: string,
data: {
amount: number;
senderClabe?: string;
senderName?: string;
senderRfc?: string;
senderBank?: string;
reference?: string;
trackingKey?: string;
providerData?: any;
},
): Promise<SpeiTransaction> {
const account = await this.getVirtualAccount(tenantId);
const transaction = this.speiRepo.create({
tenantId,
virtualAccountId: account?.id,
amount: data.amount,
senderClabe: data.senderClabe,
senderName: data.senderName,
senderRfc: data.senderRfc,
senderBank: data.senderBank,
reference: data.reference,
trackingKey: data.trackingKey,
status: SpeiTransactionStatus.RECEIVED,
receivedAt: new Date(),
providerData: data.providerData,
});
return this.speiRepo.save(transaction);
}
async reconcileSpei(id: string, saleId: string): Promise<SpeiTransaction> {
const transaction = await this.speiRepo.findOne({ where: { id } });
if (!transaction) {
throw new NotFoundException('Transaccion SPEI no encontrada');
}
transaction.saleId = saleId;
transaction.status = SpeiTransactionStatus.RECONCILED;
transaction.reconciledAt = new Date();
return this.speiRepo.save(transaction);
}
// ==================== STATS ====================
async getSummary(tenantId: string, date?: Date) {
const targetDate = date || new Date();
const dateStr = targetDate.toISOString().split('T')[0];
const result = await this.dataSource.query(
`SELECT * FROM get_codi_spei_summary($1, $2::date)`,
[tenantId, dateStr],
);
return result[0] || {
codi_count: 0,
codi_total: 0,
spei_count: 0,
spei_total: 0,
};
}
// ==================== WEBHOOKS ====================
async handleCodiWebhook(payload: any): Promise<void> {
// In production, validate webhook signature
// Find transaction by reference and confirm
const { reference, status, transactionId } = payload;
const transaction = await this.codiRepo.findOne({
where: { reference },
});
if (!transaction) {
throw new NotFoundException('Transaccion no encontrada');
}
if (status === 'confirmed') {
await this.confirmCodi(transaction.id, {
providerTransactionId: transactionId,
...payload,
});
}
}
async handleSpeiWebhook(clabe: string, payload: any): Promise<void> {
// Find virtual account by CLABE
const account = await this.virtualAccountRepo.findOne({
where: { clabe },
});
if (!account) {
throw new NotFoundException('Cuenta virtual no encontrada');
}
// Record incoming SPEI
await this.receiveSpei(account.tenantId, {
amount: payload.amount,
senderClabe: payload.senderClabe,
senderName: payload.senderName,
senderRfc: payload.senderRfc,
senderBank: payload.senderBank,
reference: payload.reference,
trackingKey: payload.trackingKey,
providerData: payload,
});
}
}

View File

@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsString, IsOptional, Min, Max } from 'class-validator';
export class GenerateQrDto {
@ApiProperty({
example: 150.5,
description: 'Monto a cobrar',
})
@IsNumber()
@Min(1)
@Max(8000) // CoDi max limit
amount: number;
@ApiProperty({
example: 'Venta #123',
description: 'Descripcion del cobro',
required: false,
})
@IsString()
@IsOptional()
description?: string;
@ApiProperty({
example: 'sale-uuid',
description: 'ID de la venta asociada',
required: false,
})
@IsString()
@IsOptional()
saleId?: string;
}

View File

@ -0,0 +1,66 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export enum CodiTransactionStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
EXPIRED = 'expired',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'sales', name: 'codi_transactions' })
export class CodiTransaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'sale_id', nullable: true })
saleId: string;
@Column({ name: 'qr_data', type: 'text' })
qrData: string;
@Column({ name: 'qr_image_url', type: 'text', nullable: true })
qrImageUrl: string;
@Column({ type: 'decimal', precision: 10, scale: 2 })
amount: number;
@Column({ length: 50, nullable: true })
reference: string;
@Column({ length: 200, nullable: true })
description: string;
@Column({
type: 'varchar',
length: 20,
default: CodiTransactionStatus.PENDING,
})
status: CodiTransactionStatus;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true })
confirmedAt: Date;
@Column({ name: 'provider_transaction_id', length: 100, nullable: true })
providerTransactionId: string;
@Column({ name: 'provider_response', type: 'jsonb', nullable: true })
providerResponse: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,82 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { VirtualAccount } from './virtual-account.entity';
export enum SpeiTransactionStatus {
RECEIVED = 'received',
RECONCILED = 'reconciled',
DISPUTED = 'disputed',
}
@Entity({ schema: 'sales', name: 'spei_transactions' })
export class SpeiTransaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'virtual_account_id', nullable: true })
virtualAccountId: string;
@Column({ name: 'sale_id', nullable: true })
saleId: string;
@Column({ type: 'decimal', precision: 10, scale: 2 })
amount: number;
@Column({ name: 'sender_clabe', length: 18, nullable: true })
senderClabe: string;
@Column({ name: 'sender_name', length: 100, nullable: true })
senderName: string;
@Column({ name: 'sender_rfc', length: 13, nullable: true })
senderRfc: string;
@Column({ name: 'sender_bank', length: 50, nullable: true })
senderBank: string;
@Column({ length: 50, nullable: true })
reference: string;
@Column({ length: 200, nullable: true })
description: string;
@Column({ name: 'tracking_key', length: 50, nullable: true })
trackingKey: string;
@Column({
type: 'varchar',
length: 20,
default: SpeiTransactionStatus.RECEIVED,
})
status: SpeiTransactionStatus;
@Column({ name: 'received_at', type: 'timestamptz' })
receivedAt: Date;
@Column({ name: 'reconciled_at', type: 'timestamptz', nullable: true })
reconciledAt: Date;
@Column({ name: 'provider_data', type: 'jsonb', nullable: true })
providerData: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@ManyToOne(() => VirtualAccount)
@JoinColumn({ name: 'virtual_account_id' })
virtualAccount: VirtualAccount;
}

View File

@ -0,0 +1,47 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export enum VirtualAccountStatus {
ACTIVE = 'active',
SUSPENDED = 'suspended',
CLOSED = 'closed',
}
@Entity({ schema: 'sales', name: 'virtual_accounts' })
export class VirtualAccount {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ length: 20, default: 'stp' })
provider: string;
@Column({ length: 18, unique: true })
clabe: string;
@Column({ name: 'beneficiary_name', length: 100, nullable: true })
beneficiaryName: string;
@Column({
type: 'varchar',
length: 20,
default: VirtualAccountStatus.ACTIVE,
})
status: VirtualAccountStatus;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,107 @@
import {
Controller,
Get,
Post,
Put,
Patch,
Param,
Body,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CustomersService } from './customers.service';
import { CreateCustomerDto, UpdateCustomerDto, CreateFiadoDto, PayFiadoDto } from './dto/customer.dto';
@ApiTags('customers')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/customers')
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
// ==================== CUSTOMERS ====================
@Get()
@ApiOperation({ summary: 'Listar todos los clientes' })
findAll(@Request() req) {
return this.customersService.findAll(req.user.tenantId);
}
@Get('with-fiados')
@ApiOperation({ summary: 'Listar clientes con fiado habilitado' })
getWithFiados(@Request() req) {
return this.customersService.getWithFiados(req.user.tenantId);
}
@Get('phone/:phone')
@ApiOperation({ summary: 'Buscar cliente por teléfono' })
findByPhone(@Request() req, @Param('phone') phone: string) {
return this.customersService.findByPhone(req.user.tenantId, phone);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener cliente por ID' })
findOne(@Request() req, @Param('id') id: string) {
return this.customersService.findOne(req.user.tenantId, id);
}
@Get(':id/stats')
@ApiOperation({ summary: 'Obtener estadísticas del cliente' })
getStats(@Request() req, @Param('id') id: string) {
return this.customersService.getCustomerStats(req.user.tenantId, id);
}
@Post()
@ApiOperation({ summary: 'Crear nuevo cliente' })
create(@Request() req, @Body() dto: CreateCustomerDto) {
return this.customersService.create(req.user.tenantId, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Actualizar cliente' })
update(@Request() req, @Param('id') id: string, @Body() dto: UpdateCustomerDto) {
return this.customersService.update(req.user.tenantId, id, dto);
}
@Patch(':id/toggle-active')
@ApiOperation({ summary: 'Activar/desactivar cliente' })
toggleActive(@Request() req, @Param('id') id: string) {
return this.customersService.toggleActive(req.user.tenantId, id);
}
// ==================== FIADOS ====================
@Get('fiados/all')
@ApiOperation({ summary: 'Listar todos los fiados' })
@ApiQuery({ name: 'customerId', required: false })
getFiados(@Request() req, @Query('customerId') customerId?: string) {
return this.customersService.getFiados(req.user.tenantId, customerId);
}
@Get('fiados/pending')
@ApiOperation({ summary: 'Listar fiados pendientes' })
getPendingFiados(@Request() req) {
return this.customersService.getPendingFiados(req.user.tenantId);
}
@Post('fiados')
@ApiOperation({ summary: 'Crear nuevo fiado' })
createFiado(@Request() req, @Body() dto: CreateFiadoDto) {
return this.customersService.createFiado(req.user.tenantId, dto);
}
@Post('fiados/:id/pay')
@ApiOperation({ summary: 'Registrar pago de fiado' })
payFiado(@Request() req, @Param('id') id: string, @Body() dto: PayFiadoDto) {
return this.customersService.payFiado(req.user.tenantId, id, dto, req.user.id);
}
@Patch('fiados/:id/cancel')
@ApiOperation({ summary: 'Cancelar fiado' })
cancelFiado(@Request() req, @Param('id') id: string) {
return this.customersService.cancelFiado(req.user.tenantId, id);
}
}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
import { Customer } from './entities/customer.entity';
import { Fiado } from './entities/fiado.entity';
import { FiadoPayment } from './entities/fiado-payment.entity';
@Module({
imports: [TypeOrmModule.forFeature([Customer, Fiado, FiadoPayment])],
controllers: [CustomersController],
providers: [CustomersService],
exports: [CustomersService],
})
export class CustomersModule {}

View File

@ -0,0 +1,231 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Customer } from './entities/customer.entity';
import { Fiado, FiadoStatus } from './entities/fiado.entity';
import { FiadoPayment } from './entities/fiado-payment.entity';
import { CreateCustomerDto, UpdateCustomerDto, CreateFiadoDto, PayFiadoDto } from './dto/customer.dto';
@Injectable()
export class CustomersService {
constructor(
@InjectRepository(Customer)
private readonly customerRepo: Repository<Customer>,
@InjectRepository(Fiado)
private readonly fiadoRepo: Repository<Fiado>,
@InjectRepository(FiadoPayment)
private readonly fiadoPaymentRepo: Repository<FiadoPayment>,
) {}
// ==================== CUSTOMERS ====================
async findAll(tenantId: string): Promise<Customer[]> {
return this.customerRepo.find({
where: { tenantId, status: 'active' },
order: { name: 'ASC' },
});
}
async findOne(tenantId: string, id: string): Promise<Customer> {
const customer = await this.customerRepo.findOne({
where: { id, tenantId },
relations: ['fiados'],
});
if (!customer) {
throw new NotFoundException('Cliente no encontrado');
}
return customer;
}
async findByPhone(tenantId: string, phone: string): Promise<Customer | null> {
return this.customerRepo.findOne({
where: { tenantId, phone },
});
}
async create(tenantId: string, dto: CreateCustomerDto): Promise<Customer> {
const customer = this.customerRepo.create({
...dto,
tenantId,
});
return this.customerRepo.save(customer);
}
async update(tenantId: string, id: string, dto: UpdateCustomerDto): Promise<Customer> {
const customer = await this.findOne(tenantId, id);
Object.assign(customer, dto);
return this.customerRepo.save(customer);
}
async toggleActive(tenantId: string, id: string): Promise<Customer> {
const customer = await this.findOne(tenantId, id);
customer.status = customer.status === 'active' ? 'inactive' : 'active';
return this.customerRepo.save(customer);
}
async getWithFiados(tenantId: string): Promise<Customer[]> {
return this.customerRepo.find({
where: { tenantId, fiadoEnabled: true },
order: { currentFiadoBalance: 'DESC' },
});
}
// ==================== FIADOS ====================
async createFiado(tenantId: string, dto: CreateFiadoDto): Promise<Fiado> {
const customer = await this.findOne(tenantId, dto.customerId);
if (!customer.fiadoEnabled) {
throw new BadRequestException('El cliente no tiene habilitado el fiado');
}
const newBalance = Number(customer.currentFiadoBalance) + dto.amount;
if (customer.fiadoLimit > 0 && newBalance > customer.fiadoLimit) {
throw new BadRequestException(
`El fiado excede el límite. Límite: $${customer.fiadoLimit}, Balance actual: $${customer.currentFiadoBalance}`,
);
}
const fiado = this.fiadoRepo.create({
tenantId,
customerId: dto.customerId,
saleId: dto.saleId,
amount: dto.amount,
remainingAmount: dto.amount,
description: dto.description,
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
status: FiadoStatus.PENDING,
});
await this.fiadoRepo.save(fiado);
// Update customer balance
customer.currentFiadoBalance = newBalance;
await this.customerRepo.save(customer);
return fiado;
}
async getFiados(tenantId: string, customerId?: string): Promise<Fiado[]> {
const where: any = { tenantId };
if (customerId) {
where.customerId = customerId;
}
return this.fiadoRepo.find({
where,
relations: ['customer', 'payments'],
order: { createdAt: 'DESC' },
});
}
async getPendingFiados(tenantId: string): Promise<Fiado[]> {
return this.fiadoRepo.find({
where: [
{ tenantId, status: FiadoStatus.PENDING },
{ tenantId, status: FiadoStatus.PARTIAL },
],
relations: ['customer'],
order: { createdAt: 'ASC' },
});
}
async payFiado(tenantId: string, fiadoId: string, dto: PayFiadoDto, userId?: string): Promise<Fiado> {
const fiado = await this.fiadoRepo.findOne({
where: { id: fiadoId, tenantId },
relations: ['customer'],
});
if (!fiado) {
throw new NotFoundException('Fiado no encontrado');
}
if (fiado.status === FiadoStatus.PAID) {
throw new BadRequestException('Este fiado ya está pagado');
}
if (dto.amount > Number(fiado.remainingAmount)) {
throw new BadRequestException(
`El monto excede el saldo pendiente: $${fiado.remainingAmount}`,
);
}
// Create payment record
const payment = this.fiadoPaymentRepo.create({
fiadoId,
amount: dto.amount,
paymentMethod: dto.paymentMethod || 'cash',
notes: dto.notes,
receivedBy: userId,
});
await this.fiadoPaymentRepo.save(payment);
// Update fiado
fiado.paidAmount = Number(fiado.paidAmount) + dto.amount;
fiado.remainingAmount = Number(fiado.remainingAmount) - dto.amount;
if (fiado.remainingAmount <= 0) {
fiado.status = FiadoStatus.PAID;
fiado.paidAt = new Date();
} else {
fiado.status = FiadoStatus.PARTIAL;
}
await this.fiadoRepo.save(fiado);
// Update customer balance
const customer = fiado.customer;
customer.currentFiadoBalance = Number(customer.currentFiadoBalance) - dto.amount;
await this.customerRepo.save(customer);
return fiado;
}
async cancelFiado(tenantId: string, fiadoId: string): Promise<Fiado> {
const fiado = await this.fiadoRepo.findOne({
where: { id: fiadoId, tenantId },
relations: ['customer'],
});
if (!fiado) {
throw new NotFoundException('Fiado no encontrado');
}
if (fiado.status === FiadoStatus.PAID) {
throw new BadRequestException('No se puede cancelar un fiado pagado');
}
// Restore customer balance
const customer = fiado.customer;
customer.currentFiadoBalance = Number(customer.currentFiadoBalance) - Number(fiado.remainingAmount);
await this.customerRepo.save(customer);
fiado.status = FiadoStatus.CANCELLED;
return this.fiadoRepo.save(fiado);
}
// ==================== STATS ====================
async getCustomerStats(tenantId: string, customerId: string) {
const customer = await this.findOne(tenantId, customerId);
const pendingFiados = await this.fiadoRepo.count({
where: [
{ customerId, status: FiadoStatus.PENDING },
{ customerId, status: FiadoStatus.PARTIAL },
],
});
return {
customer,
stats: {
totalPurchases: customer.totalPurchases,
purchaseCount: customer.purchaseCount,
fiadoBalance: customer.currentFiadoBalance,
fiadoLimit: customer.fiadoLimit,
fiadoAvailable: Math.max(0, Number(customer.fiadoLimit) - Number(customer.currentFiadoBalance)),
pendingFiados,
},
};
}
}

View File

@ -0,0 +1,103 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsEmail,
MaxLength,
Min,
IsUUID,
} from 'class-validator';
export class CreateCustomerDto {
@ApiProperty({ description: 'Nombre del cliente', example: 'Juan Pérez' })
@IsString()
@MaxLength(200)
name: string;
@ApiPropertyOptional({ description: 'Teléfono', example: '5512345678' })
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
@ApiPropertyOptional({ description: 'WhatsApp', example: '5512345678' })
@IsOptional()
@IsString()
@MaxLength(20)
whatsapp?: string;
@ApiPropertyOptional({ description: 'Email', example: 'juan@email.com' })
@IsOptional()
@IsEmail()
@MaxLength(255)
email?: string;
@ApiPropertyOptional({ description: 'Dirección' })
@IsOptional()
@IsString()
address?: string;
@ApiPropertyOptional({ description: 'Notas adicionales' })
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({ description: 'Habilitar fiado', default: false })
@IsOptional()
@IsBoolean()
fiadoEnabled?: boolean;
@ApiPropertyOptional({ description: 'Límite de fiado', example: 500 })
@IsOptional()
@IsNumber()
@Min(0)
fiadoLimit?: number;
}
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {}
export class CreateFiadoDto {
@ApiProperty({ description: 'ID del cliente' })
@IsUUID()
customerId: string;
@ApiPropertyOptional({ description: 'ID de la venta asociada' })
@IsOptional()
@IsUUID()
saleId?: string;
@ApiProperty({ description: 'Monto del fiado', example: 150.5 })
@IsNumber()
@Min(0.01)
amount: number;
@ApiPropertyOptional({ description: 'Descripción del fiado' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Fecha de vencimiento' })
@IsOptional()
@IsString()
dueDate?: string;
}
export class PayFiadoDto {
@ApiProperty({ description: 'Monto a pagar', example: 50 })
@IsNumber()
@Min(0.01)
amount: number;
@ApiPropertyOptional({ description: 'Método de pago', default: 'cash' })
@IsOptional()
@IsString()
@MaxLength(20)
paymentMethod?: string;
@ApiPropertyOptional({ description: 'Notas del pago' })
@IsOptional()
@IsString()
notes?: string;
}

View File

@ -0,0 +1,76 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { Fiado } from './fiado.entity';
@Entity({ schema: 'customers', name: 'customers' })
export class Customer {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ length: 100 })
name: string;
@Column({ length: 20, nullable: true })
phone: string;
@Column({ length: 100, nullable: true })
email: string;
@Column({ type: 'text', nullable: true })
address: string;
@Column({ name: 'address_reference', type: 'text', nullable: true })
addressReference: string;
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
latitude: number;
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
longitude: number;
@Column({ name: 'fiado_enabled', default: true })
fiadoEnabled: boolean;
@Column({ name: 'fiado_limit', type: 'decimal', precision: 10, scale: 2, nullable: true })
fiadoLimit: number;
@Column({ name: 'current_fiado_balance', type: 'decimal', precision: 10, scale: 2, default: 0 })
currentFiadoBalance: number;
@Column({ name: 'total_purchases', type: 'decimal', precision: 12, scale: 2, default: 0 })
totalPurchases: number;
@Column({ name: 'purchase_count', default: 0 })
purchaseCount: number;
@Column({ name: 'last_purchase_at', type: 'timestamptz', nullable: true })
lastPurchaseAt: Date;
@Column({ name: 'whatsapp_opt_in', default: false })
whatsappOptIn: boolean;
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ length: 20, default: 'active' })
status: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => Fiado, (fiado) => fiado.customer)
fiados: Fiado[];
}

View File

@ -0,0 +1,38 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Fiado } from './fiado.entity';
@Entity({ schema: 'customers', name: 'fiado_payments' })
export class FiadoPayment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'fiado_id' })
fiadoId: string;
@Column({ type: 'decimal', precision: 10, scale: 2 })
amount: number;
@Column({ name: 'payment_method', length: 20, default: 'cash' })
paymentMethod: string;
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'received_by', nullable: true })
receivedBy: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => Fiado, (fiado) => fiado.payments)
@JoinColumn({ name: 'fiado_id' })
fiado: Fiado;
}

View File

@ -0,0 +1,73 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { Customer } from './customer.entity';
import { FiadoPayment } from './fiado-payment.entity';
export enum FiadoStatus {
PENDING = 'pending',
PARTIAL = 'partial',
PAID = 'paid',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'customers', name: 'fiados' })
export class Fiado {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'customer_id' })
customerId: string;
@Column({ name: 'sale_id', nullable: true })
saleId: string;
@Column({ type: 'decimal', precision: 10, scale: 2 })
amount: number;
@Column({ name: 'paid_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
paidAmount: number;
@Column({ name: 'remaining_amount', type: 'decimal', precision: 10, scale: 2 })
remainingAmount: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({
type: 'varchar',
length: 20,
default: FiadoStatus.PENDING,
})
status: FiadoStatus;
@Column({ name: 'due_date', type: 'date', nullable: true })
dueDate: Date;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@ManyToOne(() => Customer, (customer) => customer.fiados)
@JoinColumn({ name: 'customer_id' })
customer: Customer;
@OneToMany(() => FiadoPayment, (payment) => payment.fiado)
payments: FiadoPayment[];
}

View File

@ -0,0 +1,263 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { TenantIntegrationsService } from '../services/tenant-integrations.service';
import {
UpsertWhatsAppCredentialsDto,
UpsertLLMCredentialsDto,
CreateIntegrationCredentialDto,
IntegrationCredentialResponseDto,
IntegrationStatusResponseDto,
} from '../dto/integration-credentials.dto';
import {
IntegrationType,
IntegrationProvider,
} from '../entities/tenant-integration-credential.entity';
@ApiTags('Integrations')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('integrations')
export class IntegrationsController {
constructor(private readonly integrationsService: TenantIntegrationsService) {}
// =========================================================================
// STATUS
// =========================================================================
@Get('status')
@ApiOperation({ summary: 'Obtener estado de todas las integraciones del tenant' })
@ApiResponse({ status: 200, type: IntegrationStatusResponseDto })
async getStatus(@Request() req): Promise<IntegrationStatusResponseDto> {
return this.integrationsService.getIntegrationStatus(req.user.tenantId);
}
// =========================================================================
// WHATSAPP
// =========================================================================
@Get('whatsapp')
@ApiOperation({ summary: 'Obtener configuración de WhatsApp' })
async getWhatsAppConfig(@Request() req) {
const credential = await this.integrationsService.getCredential(
req.user.tenantId,
IntegrationType.WHATSAPP,
IntegrationProvider.META,
);
if (!credential) {
return {
configured: false,
usesPlatformNumber: true,
message: 'Usando número de plataforma compartido',
};
}
return {
configured: true,
usesPlatformNumber: false,
isVerified: credential.isVerified,
lastVerifiedAt: credential.lastVerifiedAt,
// No exponer credenciales sensibles
hasAccessToken: !!credential.credentials?.['accessToken'],
phoneNumberId: credential.credentials?.['phoneNumberId'],
};
}
@Put('whatsapp')
@ApiOperation({ summary: 'Configurar credenciales de WhatsApp propias' })
async upsertWhatsAppCredentials(
@Request() req,
@Body() dto: UpsertWhatsAppCredentialsDto,
) {
const credential = await this.integrationsService.upsertCredential(
req.user.tenantId,
IntegrationType.WHATSAPP,
IntegrationProvider.META,
{
accessToken: dto.credentials.accessToken,
phoneNumberId: dto.credentials.phoneNumberId,
businessAccountId: dto.credentials.businessAccountId,
verifyToken: dto.credentials.verifyToken,
},
{},
req.user.sub,
);
// Registrar el número de WhatsApp para resolución en webhooks
if (dto.credentials.phoneNumberId) {
await this.integrationsService.registerWhatsAppNumber(
req.user.tenantId,
dto.credentials.phoneNumberId,
dto.phoneNumber,
dto.displayName,
);
}
return {
success: true,
message: 'Credenciales de WhatsApp configuradas',
id: credential.id,
};
}
@Delete('whatsapp')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Eliminar credenciales de WhatsApp (volver a usar plataforma)' })
async deleteWhatsAppCredentials(@Request() req) {
await this.integrationsService.deleteCredential(
req.user.tenantId,
IntegrationType.WHATSAPP,
IntegrationProvider.META,
);
}
// =========================================================================
// LLM
// =========================================================================
@Get('llm')
@ApiOperation({ summary: 'Obtener configuración de LLM' })
async getLLMConfig(@Request() req) {
const credentials = await this.integrationsService.getCredentials(req.user.tenantId);
const llmCred = credentials.find(
(c) => c.integrationType === IntegrationType.LLM && c.isActive,
);
if (!llmCred) {
return {
configured: false,
usesPlatformDefault: true,
message: 'Usando configuración LLM de plataforma',
};
}
return {
configured: true,
usesPlatformDefault: false,
provider: llmCred.provider,
isVerified: llmCred.isVerified,
config: {
model: llmCred.config?.['model'],
maxTokens: llmCred.config?.['maxTokens'],
temperature: llmCred.config?.['temperature'],
hasSystemPrompt: !!llmCred.config?.['systemPrompt'],
},
// No exponer API key
hasApiKey: !!llmCred.credentials?.['apiKey'],
};
}
@Put('llm')
@ApiOperation({ summary: 'Configurar credenciales de LLM propias' })
async upsertLLMCredentials(@Request() req, @Body() dto: UpsertLLMCredentialsDto) {
const credential = await this.integrationsService.upsertCredential(
req.user.tenantId,
IntegrationType.LLM,
dto.provider,
{ apiKey: dto.credentials.apiKey },
dto.config || {},
req.user.sub,
);
return {
success: true,
message: 'Credenciales de LLM configuradas',
id: credential.id,
provider: dto.provider,
};
}
@Delete('llm/:provider')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Eliminar credenciales de LLM (volver a usar plataforma)' })
async deleteLLMCredentials(
@Request() req,
@Param('provider') provider: IntegrationProvider,
) {
await this.integrationsService.deleteCredential(
req.user.tenantId,
IntegrationType.LLM,
provider,
);
}
// =========================================================================
// GENERIC CRUD
// =========================================================================
@Get('credentials')
@ApiOperation({ summary: 'Obtener todas las credenciales del tenant' })
async getAllCredentials(@Request() req): Promise<IntegrationCredentialResponseDto[]> {
const credentials = await this.integrationsService.getCredentials(req.user.tenantId);
return credentials.map((c) => ({
id: c.id,
integrationType: c.integrationType,
provider: c.provider,
hasCredentials: !!c.credentials && Object.keys(c.credentials).length > 0,
isActive: c.isActive,
isVerified: c.isVerified,
lastVerifiedAt: c.lastVerifiedAt,
verificationError: c.verificationError,
config: c.config,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
}));
}
@Post('credentials')
@ApiOperation({ summary: 'Crear credencial de integración genérica' })
async createCredential(
@Request() req,
@Body() dto: CreateIntegrationCredentialDto,
) {
const credential = await this.integrationsService.upsertCredential(
req.user.tenantId,
dto.integrationType,
dto.provider,
dto.credentials,
dto.config,
req.user.sub,
);
return {
success: true,
message: 'Credencial creada',
id: credential.id,
};
}
@Put('credentials/:type/:provider/toggle')
@ApiOperation({ summary: 'Activar/desactivar una credencial' })
async toggleCredential(
@Request() req,
@Param('type') type: IntegrationType,
@Param('provider') provider: IntegrationProvider,
@Body() body: { isActive: boolean },
) {
const credential = await this.integrationsService.toggleCredential(
req.user.tenantId,
type,
provider,
body.isActive,
);
return {
success: true,
isActive: credential.isActive,
};
}
}

View File

@ -0,0 +1,137 @@
import {
Controller,
Get,
Param,
Headers,
UnauthorizedException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger';
import { TenantIntegrationsService } from '../services/tenant-integrations.service';
import {
IntegrationType,
IntegrationProvider,
} from '../entities/tenant-integration-credential.entity';
/**
* Internal API for whatsapp-service to fetch tenant credentials
* Protected by X-Internal-Key header
*/
@ApiTags('Internal - Integrations')
@Controller('internal/integrations')
export class InternalIntegrationsController {
constructor(
private readonly integrationsService: TenantIntegrationsService,
private readonly configService: ConfigService,
) {}
private validateInternalKey(internalKey: string): void {
const expectedKey = this.configService.get('INTERNAL_API_KEY');
if (!expectedKey || internalKey !== expectedKey) {
throw new UnauthorizedException('Invalid internal API key');
}
}
@Get(':tenantId/whatsapp')
@ApiOperation({ summary: 'Get WhatsApp credentials for a tenant (internal use)' })
@ApiHeader({ name: 'X-Internal-Key', required: true })
@ApiResponse({ status: 200, description: 'Credentials returned' })
@ApiResponse({ status: 401, description: 'Invalid internal key' })
@ApiResponse({ status: 404, description: 'Tenant not found or not configured' })
async getWhatsAppCredentials(
@Param('tenantId') tenantId: string,
@Headers('x-internal-key') internalKey: string,
) {
this.validateInternalKey(internalKey);
const credential = await this.integrationsService.getCredential(
tenantId,
IntegrationType.WHATSAPP,
IntegrationProvider.META,
);
if (!credential || !credential.isActive) {
return {
configured: false,
message: 'WhatsApp not configured for this tenant',
};
}
return {
configured: true,
credentials: {
accessToken: credential.credentials?.['accessToken'],
phoneNumberId: credential.credentials?.['phoneNumberId'],
businessAccountId: credential.credentials?.['businessAccountId'],
verifyToken: credential.credentials?.['verifyToken'],
},
};
}
@Get(':tenantId/llm')
@ApiOperation({ summary: 'Get LLM configuration for a tenant (internal use)' })
@ApiHeader({ name: 'X-Internal-Key', required: true })
@ApiResponse({ status: 200, description: 'Configuration returned' })
@ApiResponse({ status: 401, description: 'Invalid internal key' })
async getLLMConfig(
@Param('tenantId') tenantId: string,
@Headers('x-internal-key') internalKey: string,
) {
this.validateInternalKey(internalKey);
// Get all LLM credentials and find the active one
const credentials = await this.integrationsService.getCredentials(tenantId);
const llmCredential = credentials.find(
(c) => c.integrationType === IntegrationType.LLM && c.isActive,
);
if (!llmCredential) {
return {
configured: false,
message: 'LLM not configured for this tenant',
};
}
return {
configured: true,
provider: llmCredential.provider,
credentials: {
apiKey: llmCredential.credentials?.['apiKey'],
},
config: {
model: llmCredential.config?.['model'],
maxTokens: llmCredential.config?.['maxTokens'],
temperature: llmCredential.config?.['temperature'],
baseUrl: llmCredential.config?.['baseUrl'],
systemPrompt: llmCredential.config?.['systemPrompt'],
},
};
}
@Get('resolve-tenant/:phoneNumberId')
@ApiOperation({ summary: 'Resolve tenant ID from WhatsApp phone number ID' })
@ApiHeader({ name: 'X-Internal-Key', required: true })
@ApiResponse({ status: 200, description: 'Tenant ID returned' })
@ApiResponse({ status: 401, description: 'Invalid internal key' })
async resolveTenantFromPhoneNumberId(
@Param('phoneNumberId') phoneNumberId: string,
@Headers('x-internal-key') internalKey: string,
) {
this.validateInternalKey(internalKey);
const tenantId = await this.integrationsService.resolveTenantFromPhoneNumberId(phoneNumberId);
if (!tenantId) {
return {
found: false,
message: 'No tenant found for this phone number ID',
};
}
return {
found: true,
tenantId,
};
}
}

View File

@ -0,0 +1,182 @@
import { IsEnum, IsOptional, IsObject, IsBoolean, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IntegrationType, IntegrationProvider } from '../entities/tenant-integration-credential.entity';
// DTO para credenciales de WhatsApp
export class WhatsAppCredentialsDto {
@ApiProperty({ description: 'Access Token de Meta/WhatsApp Business API' })
@IsString()
accessToken: string;
@ApiProperty({ description: 'Phone Number ID de WhatsApp Business' })
@IsString()
phoneNumberId: string;
@ApiPropertyOptional({ description: 'Business Account ID de WhatsApp' })
@IsOptional()
@IsString()
businessAccountId?: string;
@ApiPropertyOptional({ description: 'Verify Token para webhook' })
@IsOptional()
@IsString()
verifyToken?: string;
}
// DTO para credenciales de LLM
export class LLMCredentialsDto {
@ApiProperty({ description: 'API Key del proveedor LLM' })
@IsString()
apiKey: string;
}
// DTO para configuración de LLM
export class LLMConfigDto {
@ApiPropertyOptional({ description: 'Modelo a usar', example: 'gpt-4o-mini' })
@IsOptional()
@IsString()
model?: string;
@ApiPropertyOptional({ description: 'Máximo de tokens', example: 1000 })
@IsOptional()
maxTokens?: number;
@ApiPropertyOptional({ description: 'Temperatura (0-2)', example: 0.7 })
@IsOptional()
temperature?: number;
@ApiPropertyOptional({ description: 'System prompt personalizado' })
@IsOptional()
@IsString()
systemPrompt?: string;
@ApiPropertyOptional({ description: 'Base URL del API (para OpenRouter/Azure)' })
@IsOptional()
@IsString()
baseUrl?: string;
}
// DTO para crear/actualizar credenciales de WhatsApp
export class UpsertWhatsAppCredentialsDto {
@ApiProperty({ type: WhatsAppCredentialsDto })
@ValidateNested()
@Type(() => WhatsAppCredentialsDto)
credentials: WhatsAppCredentialsDto;
@ApiPropertyOptional({ description: 'Número de teléfono para display' })
@IsOptional()
@IsString()
phoneNumber?: string;
@ApiPropertyOptional({ description: 'Nombre para display' })
@IsOptional()
@IsString()
displayName?: string;
}
// DTO para crear/actualizar credenciales de LLM
export class UpsertLLMCredentialsDto {
@ApiProperty({ enum: IntegrationProvider, description: 'Proveedor LLM' })
@IsEnum(IntegrationProvider)
provider: IntegrationProvider;
@ApiProperty({ type: LLMCredentialsDto })
@ValidateNested()
@Type(() => LLMCredentialsDto)
credentials: LLMCredentialsDto;
@ApiPropertyOptional({ type: LLMConfigDto })
@IsOptional()
@ValidateNested()
@Type(() => LLMConfigDto)
config?: LLMConfigDto;
}
// DTO genérico para crear credenciales
export class CreateIntegrationCredentialDto {
@ApiProperty({ enum: IntegrationType })
@IsEnum(IntegrationType)
integrationType: IntegrationType;
@ApiProperty({ enum: IntegrationProvider })
@IsEnum(IntegrationProvider)
provider: IntegrationProvider;
@ApiProperty({ description: 'Credenciales específicas del proveedor' })
@IsObject()
credentials: Record<string, any>;
@ApiPropertyOptional({ description: 'Configuración adicional' })
@IsOptional()
@IsObject()
config?: Record<string, any>;
@ApiPropertyOptional({ description: 'Activar inmediatamente' })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
// DTO de respuesta para credenciales (sin datos sensibles)
export class IntegrationCredentialResponseDto {
@ApiProperty()
id: string;
@ApiProperty({ enum: IntegrationType })
integrationType: IntegrationType;
@ApiProperty({ enum: IntegrationProvider })
provider: IntegrationProvider;
@ApiProperty({ description: 'Indica si hay credenciales configuradas' })
hasCredentials: boolean;
@ApiProperty()
isActive: boolean;
@ApiProperty()
isVerified: boolean;
@ApiPropertyOptional()
lastVerifiedAt?: Date;
@ApiPropertyOptional()
verificationError?: string;
@ApiProperty()
config: Record<string, any>;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
}
// DTO de respuesta para estado de integraciones
export class IntegrationStatusResponseDto {
@ApiProperty()
whatsapp: {
configured: boolean;
usesPlatformNumber: boolean;
provider: string;
isVerified: boolean;
};
@ApiProperty()
llm: {
configured: boolean;
usesPlatformDefault: boolean;
provider: string;
model: string;
isVerified: boolean;
};
@ApiProperty()
payments: {
stripe: { configured: boolean; isVerified: boolean };
mercadopago: { configured: boolean; isVerified: boolean };
clip: { configured: boolean; isVerified: boolean };
};
}

View File

@ -0,0 +1,120 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { Tenant } from '../../auth/entities/tenant.entity';
export enum IntegrationType {
WHATSAPP = 'whatsapp',
LLM = 'llm',
STRIPE = 'stripe',
MERCADOPAGO = 'mercadopago',
CLIP = 'clip',
}
export enum IntegrationProvider {
// WhatsApp
META = 'meta',
// LLM
OPENAI = 'openai',
OPENROUTER = 'openrouter',
ANTHROPIC = 'anthropic',
OLLAMA = 'ollama',
AZURE_OPENAI = 'azure_openai',
// Payments
STRIPE = 'stripe',
MERCADOPAGO = 'mercadopago',
CLIP = 'clip',
}
// Tipos de credenciales para WhatsApp
export interface WhatsAppCredentials {
accessToken: string;
phoneNumberId: string;
businessAccountId?: string;
verifyToken?: string;
}
// Tipos de credenciales para LLM
export interface LLMCredentials {
apiKey: string;
}
// Tipos de configuración para LLM
export interface LLMConfig {
model?: string;
maxTokens?: number;
temperature?: number;
systemPrompt?: string;
baseUrl?: string;
}
// Tipos de credenciales para Stripe
export interface StripeCredentials {
secretKey: string;
publishableKey?: string;
webhookSecret?: string;
}
@Entity({ schema: 'public', name: 'tenant_integration_credentials' })
@Unique(['tenantId', 'integrationType', 'provider'])
export class TenantIntegrationCredential {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@Column({
name: 'integration_type',
type: 'enum',
enum: IntegrationType,
})
integrationType: IntegrationType;
@Column({
type: 'enum',
enum: IntegrationProvider,
})
provider: IntegrationProvider;
@Column({ type: 'jsonb', default: {} })
credentials: WhatsAppCredentials | LLMCredentials | StripeCredentials | Record<string, any>;
@Column({ type: 'jsonb', default: {} })
config: LLMConfig | Record<string, any>;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@Column({ name: 'is_verified', default: false })
isVerified: boolean;
@Column({ name: 'last_verified_at', type: 'timestamptz', nullable: true })
lastVerifiedAt: Date;
@Column({ name: 'verification_error', type: 'text', nullable: true })
verificationError: string;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,42 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { Tenant } from '../../auth/entities/tenant.entity';
@Entity({ schema: 'public', name: 'tenant_whatsapp_numbers' })
@Unique(['tenantId', 'phoneNumberId'])
export class TenantWhatsAppNumber {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@Column({ name: 'phone_number_id', length: 50, unique: true })
phoneNumberId: string;
@Column({ name: 'phone_number', length: 20, nullable: true })
phoneNumber: string;
@Column({ name: 'display_name', length: 100, nullable: true })
displayName: string;
@Column({ name: 'is_platform_number', default: false })
isPlatformNumber: boolean;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { TenantIntegrationCredential } from './entities/tenant-integration-credential.entity';
import { TenantWhatsAppNumber } from './entities/tenant-whatsapp-number.entity';
import { Tenant } from '../auth/entities/tenant.entity';
import { TenantIntegrationsService } from './services/tenant-integrations.service';
import { IntegrationsController } from './controllers/integrations.controller';
import { InternalIntegrationsController } from './controllers/internal-integrations.controller';
@Module({
imports: [
TypeOrmModule.forFeature([
TenantIntegrationCredential,
TenantWhatsAppNumber,
Tenant,
]),
ConfigModule,
],
controllers: [IntegrationsController, InternalIntegrationsController],
providers: [TenantIntegrationsService],
exports: [TenantIntegrationsService],
})
export class IntegrationsModule {}

View File

@ -0,0 +1,424 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import {
TenantIntegrationCredential,
IntegrationType,
IntegrationProvider,
WhatsAppCredentials,
LLMCredentials,
LLMConfig,
} from '../entities/tenant-integration-credential.entity';
import { TenantWhatsAppNumber } from '../entities/tenant-whatsapp-number.entity';
import { Tenant } from '../../auth/entities/tenant.entity';
// Interfaces para credenciales resueltas
export interface ResolvedWhatsAppCredentials {
accessToken: string;
phoneNumberId: string;
businessAccountId?: string;
verifyToken?: string;
isFromPlatform: boolean;
tenantId?: string;
}
export interface ResolvedLLMConfig {
apiKey: string;
provider: string;
model: string;
maxTokens: number;
temperature: number;
baseUrl: string;
systemPrompt?: string;
isFromPlatform: boolean;
tenantId?: string;
}
@Injectable()
export class TenantIntegrationsService {
constructor(
@InjectRepository(TenantIntegrationCredential)
private readonly credentialRepo: Repository<TenantIntegrationCredential>,
@InjectRepository(TenantWhatsAppNumber)
private readonly whatsappNumberRepo: Repository<TenantWhatsAppNumber>,
@InjectRepository(Tenant)
private readonly tenantRepo: Repository<Tenant>,
private readonly configService: ConfigService,
) {}
// =========================================================================
// WHATSAPP CREDENTIALS
// =========================================================================
/**
* Obtiene las credenciales de WhatsApp para un tenant
* Si el tenant no tiene credenciales propias, retorna las de plataforma
*/
async getWhatsAppCredentials(tenantId: string): Promise<ResolvedWhatsAppCredentials> {
// Buscar credenciales del tenant
const tenantCredential = await this.credentialRepo.findOne({
where: {
tenantId,
integrationType: IntegrationType.WHATSAPP,
provider: IntegrationProvider.META,
isActive: true,
},
});
if (tenantCredential && tenantCredential.credentials) {
const creds = tenantCredential.credentials as WhatsAppCredentials;
if (creds.accessToken && creds.phoneNumberId) {
return {
accessToken: creds.accessToken,
phoneNumberId: creds.phoneNumberId,
businessAccountId: creds.businessAccountId,
verifyToken: creds.verifyToken,
isFromPlatform: false,
tenantId,
};
}
}
// Fallback a credenciales de plataforma
return this.getPlatformWhatsAppCredentials();
}
/**
* Obtiene las credenciales de WhatsApp de la plataforma (fallback)
*/
getPlatformWhatsAppCredentials(): ResolvedWhatsAppCredentials {
const accessToken = this.configService.get<string>('WHATSAPP_ACCESS_TOKEN');
const phoneNumberId = this.configService.get<string>('WHATSAPP_PHONE_NUMBER_ID');
if (!accessToken || !phoneNumberId) {
throw new BadRequestException('WhatsApp platform credentials not configured');
}
return {
accessToken,
phoneNumberId,
businessAccountId: this.configService.get<string>('WHATSAPP_BUSINESS_ACCOUNT_ID'),
verifyToken: this.configService.get<string>('WHATSAPP_VERIFY_TOKEN'),
isFromPlatform: true,
};
}
/**
* Resuelve el tenant desde un phoneNumberId de webhook
*/
async resolveTenantFromPhoneNumberId(phoneNumberId: string): Promise<string | null> {
// Primero buscar en la tabla de números de WhatsApp
const whatsappNumber = await this.whatsappNumberRepo.findOne({
where: { phoneNumberId, isActive: true },
});
if (whatsappNumber) {
return whatsappNumber.tenantId;
}
// Si es el número de plataforma, retornar null (se manejará diferente)
const platformPhoneNumberId = this.configService.get<string>('WHATSAPP_PHONE_NUMBER_ID');
if (phoneNumberId === platformPhoneNumberId) {
return null; // Es un mensaje al número de plataforma
}
// Buscar en credenciales de tenant
const credential = await this.credentialRepo.findOne({
where: {
integrationType: IntegrationType.WHATSAPP,
isActive: true,
},
});
if (credential) {
const creds = credential.credentials as WhatsAppCredentials;
if (creds.phoneNumberId === phoneNumberId) {
return credential.tenantId;
}
}
return null;
}
// =========================================================================
// LLM CREDENTIALS
// =========================================================================
/**
* Obtiene la configuración LLM para un tenant
* Si el tenant no tiene configuración propia, retorna la de plataforma
*/
async getLLMConfig(tenantId: string): Promise<ResolvedLLMConfig> {
// Buscar credenciales del tenant
const tenant = await this.tenantRepo.findOne({ where: { id: tenantId } });
const preferredProvider = tenant?.preferredLlmProvider || 'openai';
const tenantCredential = await this.credentialRepo.findOne({
where: {
tenantId,
integrationType: IntegrationType.LLM,
isActive: true,
},
});
if (tenantCredential && tenantCredential.credentials) {
const creds = tenantCredential.credentials as LLMCredentials;
const config = tenantCredential.config as LLMConfig;
if (creds.apiKey) {
return {
apiKey: creds.apiKey,
provider: tenantCredential.provider,
model: config.model || this.getDefaultModel(tenantCredential.provider),
maxTokens: config.maxTokens || 1000,
temperature: config.temperature || 0.7,
baseUrl: config.baseUrl || this.getDefaultBaseUrl(tenantCredential.provider),
systemPrompt: config.systemPrompt,
isFromPlatform: false,
tenantId,
};
}
}
// Fallback a configuración de plataforma
return this.getPlatformLLMConfig();
}
/**
* Obtiene la configuración LLM de la plataforma (fallback)
*/
getPlatformLLMConfig(): ResolvedLLMConfig {
const apiKey = this.configService.get<string>('OPENAI_API_KEY') ||
this.configService.get<string>('LLM_API_KEY');
const provider = this.configService.get<string>('LLM_PROVIDER', 'openai');
if (!apiKey) {
throw new BadRequestException('LLM platform credentials not configured');
}
return {
apiKey,
provider,
model: this.configService.get<string>('LLM_MODEL', 'gpt-4o-mini'),
maxTokens: parseInt(this.configService.get<string>('LLM_MAX_TOKENS', '1000')),
temperature: parseFloat(this.configService.get<string>('LLM_TEMPERATURE', '0.7')),
baseUrl: this.configService.get<string>('LLM_BASE_URL', 'https://api.openai.com/v1'),
isFromPlatform: true,
};
}
private getDefaultModel(provider: IntegrationProvider): string {
const defaults: Record<string, string> = {
[IntegrationProvider.OPENAI]: 'gpt-4o-mini',
[IntegrationProvider.OPENROUTER]: 'anthropic/claude-3-haiku',
[IntegrationProvider.ANTHROPIC]: 'claude-3-haiku-20240307',
[IntegrationProvider.OLLAMA]: 'llama2',
[IntegrationProvider.AZURE_OPENAI]: 'gpt-4o-mini',
};
return defaults[provider] || 'gpt-4o-mini';
}
private getDefaultBaseUrl(provider: IntegrationProvider): string {
const defaults: Record<string, string> = {
[IntegrationProvider.OPENAI]: 'https://api.openai.com/v1',
[IntegrationProvider.OPENROUTER]: 'https://openrouter.ai/api/v1',
[IntegrationProvider.ANTHROPIC]: 'https://api.anthropic.com/v1',
[IntegrationProvider.OLLAMA]: 'http://localhost:11434/v1',
};
return defaults[provider] || 'https://api.openai.com/v1';
}
// =========================================================================
// CRUD OPERATIONS
// =========================================================================
/**
* Crea o actualiza credenciales de integración
*/
async upsertCredential(
tenantId: string,
integrationType: IntegrationType,
provider: IntegrationProvider,
credentials: Record<string, any>,
config?: Record<string, any>,
userId?: string,
): Promise<TenantIntegrationCredential> {
let credential = await this.credentialRepo.findOne({
where: { tenantId, integrationType, provider },
});
if (credential) {
credential.credentials = credentials;
credential.config = config || credential.config;
credential.updatedBy = userId;
credential.isVerified = false; // Resetear verificación al cambiar credenciales
} else {
credential = this.credentialRepo.create({
tenantId,
integrationType,
provider,
credentials,
config: config || {},
createdBy: userId,
updatedBy: userId,
});
}
return this.credentialRepo.save(credential);
}
/**
* Obtiene todas las credenciales de un tenant
*/
async getCredentials(tenantId: string): Promise<TenantIntegrationCredential[]> {
return this.credentialRepo.find({
where: { tenantId },
order: { integrationType: 'ASC', provider: 'ASC' },
});
}
/**
* Obtiene una credencial específica
*/
async getCredential(
tenantId: string,
integrationType: IntegrationType,
provider: IntegrationProvider,
): Promise<TenantIntegrationCredential | null> {
return this.credentialRepo.findOne({
where: { tenantId, integrationType, provider },
});
}
/**
* Elimina una credencial
*/
async deleteCredential(
tenantId: string,
integrationType: IntegrationType,
provider: IntegrationProvider,
): Promise<void> {
await this.credentialRepo.delete({ tenantId, integrationType, provider });
}
/**
* Activa o desactiva una credencial
*/
async toggleCredential(
tenantId: string,
integrationType: IntegrationType,
provider: IntegrationProvider,
isActive: boolean,
): Promise<TenantIntegrationCredential> {
const credential = await this.credentialRepo.findOne({
where: { tenantId, integrationType, provider },
});
if (!credential) {
throw new NotFoundException('Credential not found');
}
credential.isActive = isActive;
return this.credentialRepo.save(credential);
}
/**
* Obtiene el estado de todas las integraciones de un tenant
*/
async getIntegrationStatus(tenantId: string): Promise<{
whatsapp: { configured: boolean; usesPlatformNumber: boolean; provider: string; isVerified: boolean };
llm: { configured: boolean; usesPlatformDefault: boolean; provider: string; model: string; isVerified: boolean };
payments: {
stripe: { configured: boolean; isVerified: boolean };
mercadopago: { configured: boolean; isVerified: boolean };
clip: { configured: boolean; isVerified: boolean };
};
}> {
const credentials = await this.getCredentials(tenantId);
const tenant = await this.tenantRepo.findOne({ where: { id: tenantId } });
const whatsappCred = credentials.find(
(c) => c.integrationType === IntegrationType.WHATSAPP && c.isActive,
);
const llmCred = credentials.find(
(c) => c.integrationType === IntegrationType.LLM && c.isActive,
);
const stripeCred = credentials.find(
(c) => c.integrationType === IntegrationType.STRIPE && c.isActive,
);
const mercadopagoCred = credentials.find(
(c) => c.integrationType === IntegrationType.MERCADOPAGO && c.isActive,
);
const clipCred = credentials.find(
(c) => c.integrationType === IntegrationType.CLIP && c.isActive,
);
return {
whatsapp: {
configured: !!whatsappCred,
usesPlatformNumber: tenant?.usesPlatformNumber ?? true,
provider: whatsappCred?.provider || 'meta',
isVerified: whatsappCred?.isVerified ?? false,
},
llm: {
configured: !!llmCred,
usesPlatformDefault: !llmCred,
provider: llmCred?.provider || tenant?.preferredLlmProvider || 'openai',
model: (llmCred?.config as LLMConfig)?.model || 'gpt-4o-mini',
isVerified: llmCred?.isVerified ?? false,
},
payments: {
stripe: {
configured: !!stripeCred,
isVerified: stripeCred?.isVerified ?? false,
},
mercadopago: {
configured: !!mercadopagoCred,
isVerified: mercadopagoCred?.isVerified ?? false,
},
clip: {
configured: !!clipCred,
isVerified: clipCred?.isVerified ?? false,
},
},
};
}
// =========================================================================
// WHATSAPP NUMBER MAPPING
// =========================================================================
/**
* Registra un número de WhatsApp para un tenant
*/
async registerWhatsAppNumber(
tenantId: string,
phoneNumberId: string,
phoneNumber?: string,
displayName?: string,
isPlatformNumber = false,
): Promise<TenantWhatsAppNumber> {
let mapping = await this.whatsappNumberRepo.findOne({
where: { phoneNumberId },
});
if (mapping) {
mapping.tenantId = tenantId;
mapping.phoneNumber = phoneNumber || mapping.phoneNumber;
mapping.displayName = displayName || mapping.displayName;
mapping.isPlatformNumber = isPlatformNumber;
} else {
mapping = this.whatsappNumberRepo.create({
tenantId,
phoneNumberId,
phoneNumber,
displayName,
isPlatformNumber,
});
}
return this.whatsappNumberRepo.save(mapping);
}
}

View File

@ -0,0 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsUUID, IsEnum, Min } from 'class-validator';
import { MovementType } from '../entities/inventory-movement.entity';
export class CreateMovementDto {
@ApiProperty({ description: 'ID del producto' })
@IsUUID()
productId: string;
@ApiProperty({ enum: MovementType, description: 'Tipo de movimiento' })
@IsEnum(MovementType)
movementType: MovementType;
@ApiProperty({ description: 'Cantidad (positiva para entrada, negativa para salida)', example: 10 })
@IsNumber()
quantity: number;
@ApiPropertyOptional({ description: 'Costo unitario', example: 15.5 })
@IsOptional()
@IsNumber()
@Min(0)
unitCost?: number;
@ApiPropertyOptional({ description: 'Tipo de referencia (sale, purchase, etc.)' })
@IsOptional()
@IsString()
referenceType?: string;
@ApiPropertyOptional({ description: 'ID de referencia' })
@IsOptional()
@IsUUID()
referenceId?: string;
@ApiPropertyOptional({ description: 'Notas' })
@IsOptional()
@IsString()
notes?: string;
}
export class AdjustStockDto {
@ApiProperty({ description: 'ID del producto' })
@IsUUID()
productId: string;
@ApiProperty({ description: 'Nueva cantidad en stock', example: 50 })
@IsNumber()
@Min(0)
newQuantity: number;
@ApiPropertyOptional({ description: 'Razón del ajuste' })
@IsOptional()
@IsString()
reason?: string;
}
export class StockQueryDto {
@ApiPropertyOptional({ description: 'Solo productos con bajo stock' })
@IsOptional()
lowStock?: boolean;
@ApiPropertyOptional({ description: 'Solo productos sin stock' })
@IsOptional()
outOfStock?: boolean;
}

View File

@ -0,0 +1,69 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Product } from '../../products/entities/product.entity';
export enum MovementType {
PURCHASE = 'purchase',
SALE = 'sale',
ADJUSTMENT = 'adjustment',
LOSS = 'loss',
RETURN = 'return',
TRANSFER = 'transfer',
}
@Entity({ schema: 'inventory', name: 'inventory_movements' })
export class InventoryMovement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'product_id' })
productId: string;
@Column({
name: 'movement_type',
type: 'varchar',
length: 20,
})
movementType: MovementType;
@Column({ type: 'decimal', precision: 10, scale: 3 })
quantity: number;
@Column({ name: 'quantity_before', type: 'decimal', precision: 10, scale: 3 })
quantityBefore: number;
@Column({ name: 'quantity_after', type: 'decimal', precision: 10, scale: 3 })
quantityAfter: number;
@Column({ name: 'unit_cost', type: 'decimal', precision: 10, scale: 2, nullable: true })
unitCost: number;
@Column({ name: 'reference_type', length: 20, nullable: true })
referenceType: string;
@Column({ name: 'reference_id', nullable: true })
referenceId: string;
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'created_by', nullable: true })
createdBy: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => Product)
@JoinColumn({ name: 'product_id' })
product: Product;
}

View File

@ -0,0 +1,64 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Product } from '../../products/entities/product.entity';
export enum AlertType {
LOW_STOCK = 'low_stock',
OUT_OF_STOCK = 'out_of_stock',
EXPIRING = 'expiring',
}
export enum AlertStatus {
ACTIVE = 'active',
RESOLVED = 'resolved',
DISMISSED = 'dismissed',
}
@Entity({ schema: 'inventory', name: 'stock_alerts' })
export class StockAlert {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'product_id' })
productId: string;
@Column({
name: 'alert_type',
type: 'varchar',
length: 20,
})
alertType: AlertType;
@Column({
type: 'varchar',
length: 20,
default: AlertStatus.ACTIVE,
})
status: AlertStatus;
@Column({ name: 'current_stock', type: 'decimal', precision: 10, scale: 3 })
currentStock: number;
@Column({ name: 'threshold', type: 'decimal', precision: 10, scale: 3 })
threshold: number;
@Column({ name: 'resolved_at', type: 'timestamptz', nullable: true })
resolvedAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => Product)
@JoinColumn({ name: 'product_id' })
product: Product;
}

View File

@ -0,0 +1,91 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { InventoryService } from './inventory.service';
import { CreateMovementDto, AdjustStockDto } from './dto/inventory.dto';
@ApiTags('inventory')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/inventory')
export class InventoryController {
constructor(private readonly inventoryService: InventoryService) {}
// ==================== MOVEMENTS ====================
@Get('movements')
@ApiOperation({ summary: 'Listar movimientos de inventario' })
@ApiQuery({ name: 'productId', required: false })
@ApiQuery({ name: 'limit', required: false })
getMovements(
@Request() req,
@Query('productId') productId?: string,
@Query('limit') limit?: number,
) {
return this.inventoryService.getMovements(req.user.tenantId, productId, limit);
}
@Get('movements/product/:productId')
@ApiOperation({ summary: 'Historial de movimientos de un producto' })
getProductHistory(@Request() req, @Param('productId') productId: string) {
return this.inventoryService.getProductHistory(req.user.tenantId, productId);
}
@Post('movements')
@ApiOperation({ summary: 'Registrar movimiento de inventario' })
createMovement(@Request() req, @Body() dto: CreateMovementDto) {
return this.inventoryService.createMovement(req.user.tenantId, dto, req.user.id);
}
@Post('adjust')
@ApiOperation({ summary: 'Ajustar stock de un producto' })
adjustStock(@Request() req, @Body() dto: AdjustStockDto) {
return this.inventoryService.adjustStock(req.user.tenantId, dto, req.user.id);
}
// ==================== ALERTS ====================
@Get('alerts')
@ApiOperation({ summary: 'Listar alertas activas de stock' })
getAlerts(@Request() req) {
return this.inventoryService.getActiveAlerts(req.user.tenantId);
}
@Patch('alerts/:id/dismiss')
@ApiOperation({ summary: 'Descartar una alerta' })
dismissAlert(@Request() req, @Param('id') id: string) {
return this.inventoryService.dismissAlert(req.user.tenantId, id);
}
// ==================== LOW STOCK ====================
@Get('low-stock')
@ApiOperation({ summary: 'Productos con bajo stock' })
getLowStock(@Request() req) {
return this.inventoryService.getLowStockProducts(req.user.tenantId);
}
@Get('out-of-stock')
@ApiOperation({ summary: 'Productos sin stock' })
getOutOfStock(@Request() req) {
return this.inventoryService.getOutOfStockProducts(req.user.tenantId);
}
// ==================== STATS ====================
@Get('stats')
@ApiOperation({ summary: 'Estadísticas de inventario' })
getStats(@Request() req) {
return this.inventoryService.getInventoryStats(req.user.tenantId);
}
}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
import { InventoryMovement } from './entities/inventory-movement.entity';
import { StockAlert } from './entities/stock-alert.entity';
import { Product } from '../products/entities/product.entity';
@Module({
imports: [TypeOrmModule.forFeature([InventoryMovement, StockAlert, Product])],
controllers: [InventoryController],
providers: [InventoryService],
exports: [InventoryService],
})
export class InventoryModule {}

View File

@ -0,0 +1,232 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThanOrEqual } from 'typeorm';
import { InventoryMovement, MovementType } from './entities/inventory-movement.entity';
import { StockAlert, AlertType, AlertStatus } from './entities/stock-alert.entity';
import { Product } from '../products/entities/product.entity';
import { CreateMovementDto, AdjustStockDto } from './dto/inventory.dto';
@Injectable()
export class InventoryService {
constructor(
@InjectRepository(InventoryMovement)
private readonly movementRepo: Repository<InventoryMovement>,
@InjectRepository(StockAlert)
private readonly alertRepo: Repository<StockAlert>,
@InjectRepository(Product)
private readonly productRepo: Repository<Product>,
) {}
// ==================== MOVEMENTS ====================
async createMovement(tenantId: string, dto: CreateMovementDto, userId?: string): Promise<InventoryMovement> {
const product = await this.productRepo.findOne({
where: { id: dto.productId, tenantId },
});
if (!product) {
throw new NotFoundException('Producto no encontrado');
}
const quantityBefore = Number(product.stockQuantity);
const quantityAfter = quantityBefore + dto.quantity;
if (quantityAfter < 0) {
throw new BadRequestException(
`Stock insuficiente. Disponible: ${quantityBefore}, Solicitado: ${Math.abs(dto.quantity)}`,
);
}
// Create movement record
const movement = this.movementRepo.create({
tenantId,
productId: dto.productId,
movementType: dto.movementType,
quantity: dto.quantity,
quantityBefore,
quantityAfter,
unitCost: dto.unitCost,
referenceType: dto.referenceType,
referenceId: dto.referenceId,
notes: dto.notes,
createdBy: userId,
});
await this.movementRepo.save(movement);
// Update product stock
product.stockQuantity = quantityAfter;
await this.productRepo.save(product);
// Check for alerts
await this.checkStockAlerts(tenantId, product);
return movement;
}
async adjustStock(tenantId: string, dto: AdjustStockDto, userId?: string): Promise<InventoryMovement> {
const product = await this.productRepo.findOne({
where: { id: dto.productId, tenantId },
});
if (!product) {
throw new NotFoundException('Producto no encontrado');
}
const quantityBefore = Number(product.stockQuantity);
const difference = dto.newQuantity - quantityBefore;
return this.createMovement(
tenantId,
{
productId: dto.productId,
movementType: MovementType.ADJUSTMENT,
quantity: difference,
notes: dto.reason || `Ajuste de inventario: ${quantityBefore} -> ${dto.newQuantity}`,
},
userId,
);
}
async getMovements(tenantId: string, productId?: string, limit = 50): Promise<InventoryMovement[]> {
const where: any = { tenantId };
if (productId) {
where.productId = productId;
}
return this.movementRepo.find({
where,
relations: ['product'],
order: { createdAt: 'DESC' },
take: limit,
});
}
async getProductHistory(tenantId: string, productId: string): Promise<InventoryMovement[]> {
return this.movementRepo.find({
where: { tenantId, productId },
order: { createdAt: 'DESC' },
});
}
// ==================== ALERTS ====================
async checkStockAlerts(tenantId: string, product: Product): Promise<void> {
const currentStock = Number(product.stockQuantity);
const threshold = Number(product.lowStockThreshold);
// Resolve existing alerts if stock is restored
if (currentStock > threshold) {
await this.alertRepo.update(
{ productId: product.id, status: AlertStatus.ACTIVE },
{ status: AlertStatus.RESOLVED, resolvedAt: new Date() },
);
return;
}
// Check for existing active alert
const existingAlert = await this.alertRepo.findOne({
where: { productId: product.id, status: AlertStatus.ACTIVE },
});
if (existingAlert) {
existingAlert.currentStock = currentStock;
await this.alertRepo.save(existingAlert);
return;
}
// Create new alert
const alertType = currentStock <= 0 ? AlertType.OUT_OF_STOCK : AlertType.LOW_STOCK;
const alert = this.alertRepo.create({
tenantId,
productId: product.id,
alertType,
currentStock,
threshold,
});
await this.alertRepo.save(alert);
}
async getActiveAlerts(tenantId: string): Promise<StockAlert[]> {
return this.alertRepo.find({
where: { tenantId, status: AlertStatus.ACTIVE },
relations: ['product'],
order: { createdAt: 'DESC' },
});
}
async dismissAlert(tenantId: string, alertId: string): Promise<StockAlert> {
const alert = await this.alertRepo.findOne({
where: { id: alertId, tenantId },
});
if (!alert) {
throw new NotFoundException('Alerta no encontrada');
}
alert.status = AlertStatus.DISMISSED;
return this.alertRepo.save(alert);
}
// ==================== LOW STOCK ====================
async getLowStockProducts(tenantId: string): Promise<Product[]> {
return this.productRepo
.createQueryBuilder('product')
.where('product.tenant_id = :tenantId', { tenantId })
.andWhere('product.track_inventory = true')
.andWhere('product.stock_quantity <= product.low_stock_threshold')
.andWhere("product.status = 'active'")
.orderBy('product.stock_quantity', 'ASC')
.getMany();
}
async getOutOfStockProducts(tenantId: string): Promise<Product[]> {
return this.productRepo.find({
where: {
tenantId,
trackInventory: true,
stockQuantity: LessThanOrEqual(0),
status: 'active',
},
order: { name: 'ASC' },
});
}
// ==================== STATS ====================
async getInventoryStats(tenantId: string) {
const products = await this.productRepo.find({
where: { tenantId, trackInventory: true, status: 'active' },
});
let totalValue = 0;
let lowStockCount = 0;
let outOfStockCount = 0;
for (const product of products) {
const stock = Number(product.stockQuantity);
const cost = Number(product.costPrice) || 0;
totalValue += stock * cost;
if (stock <= 0) {
outOfStockCount++;
} else if (stock <= Number(product.lowStockThreshold)) {
lowStockCount++;
}
}
const activeAlerts = await this.alertRepo.count({
where: { tenantId, status: AlertStatus.ACTIVE },
});
return {
totalProducts: products.length,
totalValue,
lowStockCount,
outOfStockCount,
activeAlerts,
};
}
}

View File

@ -0,0 +1,120 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsNumber,
IsOptional,
IsArray,
ValidateNested,
Length,
IsEmail,
Min,
} from 'class-validator';
import { Type } from 'class-transformer';
export class InvoiceItemDto {
@ApiProperty({ example: '50202301', description: 'Clave producto/servicio SAT' })
@IsString()
@Length(8, 8)
claveProdServ: string;
@ApiProperty({ example: 'Coca-Cola 600ml', description: 'Descripcion del producto' })
@IsString()
descripcion: string;
@ApiProperty({ example: 2, description: 'Cantidad' })
@IsNumber()
@Min(0.000001)
cantidad: number;
@ApiProperty({ example: 'H87', description: 'Clave unidad SAT' })
@IsString()
@Length(2, 3)
claveUnidad: string;
@ApiProperty({ example: 'Pieza', description: 'Descripcion de la unidad', required: false })
@IsString()
@IsOptional()
unidad?: string;
@ApiProperty({ example: 18.00, description: 'Valor unitario' })
@IsNumber()
@Min(0)
valorUnitario: number;
@ApiProperty({ example: 0, description: 'Descuento', required: false })
@IsNumber()
@IsOptional()
descuento?: number;
@ApiProperty({ example: 'product-uuid', description: 'ID del producto', required: false })
@IsString()
@IsOptional()
productId?: string;
}
export class CreateInvoiceDto {
@ApiProperty({ example: 'sale-uuid', description: 'ID de la venta asociada', required: false })
@IsString()
@IsOptional()
saleId?: string;
// Receptor
@ApiProperty({ example: 'XAXX010101000', description: 'RFC del receptor' })
@IsString()
@Length(12, 13)
receptorRfc: string;
@ApiProperty({ example: 'Juan Perez', description: 'Nombre o razon social' })
@IsString()
receptorNombre: string;
@ApiProperty({ example: '601', description: 'Regimen fiscal del receptor', required: false })
@IsString()
@IsOptional()
@Length(3, 3)
receptorRegimenFiscal?: string;
@ApiProperty({ example: '06600', description: 'Codigo postal del receptor' })
@IsString()
@Length(5, 5)
receptorCodigoPostal: string;
@ApiProperty({ example: 'G03', description: 'Uso del CFDI' })
@IsString()
@Length(3, 4)
receptorUsoCfdi: string;
@ApiProperty({ example: 'cliente@email.com', description: 'Email para envio', required: false })
@IsEmail()
@IsOptional()
receptorEmail?: string;
// Pago
@ApiProperty({ example: '01', description: 'Forma de pago SAT (01=Efectivo, 04=Tarjeta)' })
@IsString()
@Length(2, 2)
formaPago: string;
@ApiProperty({ example: 'PUE', description: 'Metodo de pago (PUE=Una sola exhibicion)' })
@IsString()
@Length(3, 3)
metodoPago: string;
@ApiProperty({ example: 'Contado', description: 'Condiciones de pago', required: false })
@IsString()
@IsOptional()
condicionesPago?: string;
// Items
@ApiProperty({ type: [InvoiceItemDto], description: 'Conceptos de la factura' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceItemDto)
items: InvoiceItemDto[];
// Opcional
@ApiProperty({ description: 'Notas adicionales', required: false })
@IsString()
@IsOptional()
notes?: string;
}

View File

@ -0,0 +1,64 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Invoice } from './invoice.entity';
@Entity({ schema: 'billing', name: 'invoice_items' })
export class InvoiceItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'invoice_id' })
invoiceId: string;
@Column({ name: 'product_id', nullable: true })
productId: string;
// Clave SAT
@Column({ name: 'clave_prod_serv', length: 8 })
claveProdServ: string;
@Column({ name: 'no_identificacion', length: 100, nullable: true })
noIdentificacion: string;
// Descripcion
@Column({ length: 1000 })
descripcion: string;
// Cantidad
@Column({ type: 'decimal', precision: 12, scale: 6 })
cantidad: number;
@Column({ name: 'clave_unidad', length: 3 })
claveUnidad: string;
@Column({ length: 20, nullable: true })
unidad: string;
// Precios
@Column({ name: 'valor_unitario', type: 'decimal', precision: 12, scale: 6 })
valorUnitario: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
descuento: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
importe: number;
// Objeto de impuesto
@Column({ name: 'objeto_imp', length: 2, default: '02' })
objetoImp: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => Invoice, (invoice) => invoice.items)
@JoinColumn({ name: 'invoice_id' })
invoice: Invoice;
}

View File

@ -0,0 +1,152 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { InvoiceItem } from './invoice-item.entity';
export enum InvoiceType {
INGRESO = 'I',
EGRESO = 'E',
TRASLADO = 'T',
PAGO = 'P',
NOMINA = 'N',
}
export enum InvoiceStatus {
DRAFT = 'draft',
PENDING = 'pending',
STAMPED = 'stamped',
SENT = 'sent',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'billing', name: 'invoices' })
export class Invoice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'sale_id', nullable: true })
saleId: string;
// Tipo
@Column({ name: 'tipo_comprobante', length: 1, default: InvoiceType.INGRESO })
tipoComprobante: InvoiceType;
// Folio fiscal
@Column({ length: 36, unique: true, nullable: true })
uuid: string;
@Column({ length: 10, nullable: true })
serie: string;
@Column({ nullable: true })
folio: number;
// Receptor
@Column({ name: 'receptor_rfc', length: 13 })
receptorRfc: string;
@Column({ name: 'receptor_nombre', length: 200 })
receptorNombre: string;
@Column({ name: 'receptor_regimen_fiscal', length: 3, nullable: true })
receptorRegimenFiscal: string;
@Column({ name: 'receptor_codigo_postal', length: 5 })
receptorCodigoPostal: string;
@Column({ name: 'receptor_uso_cfdi', length: 4 })
receptorUsoCfdi: string;
@Column({ name: 'receptor_email', length: 200, nullable: true })
receptorEmail: string;
// Montos
@Column({ type: 'decimal', precision: 12, scale: 2 })
subtotal: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
descuento: number;
@Column({ name: 'total_impuestos_trasladados', type: 'decimal', precision: 12, scale: 2, default: 0 })
totalImpuestosTrasladados: number;
@Column({ name: 'total_impuestos_retenidos', type: 'decimal', precision: 12, scale: 2, default: 0 })
totalImpuestosRetenidos: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
total: number;
// Pago
@Column({ name: 'forma_pago', length: 2 })
formaPago: string;
@Column({ name: 'metodo_pago', length: 3 })
metodoPago: string;
@Column({ name: 'condiciones_pago', length: 100, nullable: true })
condicionesPago: string;
// Moneda
@Column({ length: 3, default: 'MXN' })
moneda: string;
@Column({ name: 'tipo_cambio', type: 'decimal', precision: 10, scale: 6, default: 1 })
tipoCambio: number;
// Archivos
@Column({ name: 'xml_url', type: 'text', nullable: true })
xmlUrl: string;
@Column({ name: 'pdf_url', type: 'text', nullable: true })
pdfUrl: string;
@Column({ name: 'qr_url', type: 'text', nullable: true })
qrUrl: string;
// Estado
@Column({
type: 'varchar',
length: 20,
default: InvoiceStatus.DRAFT,
})
status: InvoiceStatus;
// Cancelacion
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt: Date;
@Column({ name: 'cancel_reason', length: 2, nullable: true })
cancelReason: string;
@Column({ name: 'cancel_uuid_replacement', length: 36, nullable: true })
cancelUuidReplacement: string;
// Timbrado
@Column({ name: 'stamped_at', type: 'timestamptz', nullable: true })
stampedAt: Date;
@Column({ name: 'pac_response', type: 'jsonb', nullable: true })
pacResponse: Record<string, any>;
// Metadata
@Column({ type: 'text', nullable: true })
notes: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => InvoiceItem, (item) => item.invoice)
items: InvoiceItem[];
}

View File

@ -0,0 +1,87 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export enum TaxConfigStatus {
PENDING = 'pending',
ACTIVE = 'active',
SUSPENDED = 'suspended',
}
@Entity({ schema: 'billing', name: 'tax_configs' })
export class TaxConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', unique: true })
tenantId: string;
// Datos fiscales
@Column({ length: 13 })
rfc: string;
@Column({ name: 'razon_social', length: 200 })
razonSocial: string;
@Column({ name: 'regimen_fiscal', length: 3 })
regimenFiscal: string;
@Column({ name: 'codigo_postal', length: 5 })
codigoPostal: string;
// CSD (encrypted fields)
@Column({ name: 'csd_certificate', type: 'text', nullable: true })
csdCertificate: string;
@Column({ name: 'csd_private_key_encrypted', type: 'text', nullable: true })
csdPrivateKeyEncrypted: string;
@Column({ name: 'csd_password_encrypted', type: 'text', nullable: true })
csdPasswordEncrypted: string;
@Column({ name: 'csd_valid_from', type: 'timestamptz', nullable: true })
csdValidFrom: Date;
@Column({ name: 'csd_valid_to', type: 'timestamptz', nullable: true })
csdValidTo: Date;
// PAC
@Column({ name: 'pac_provider', length: 20, default: 'facturapi' })
pacProvider: string;
@Column({ name: 'pac_api_key_encrypted', type: 'text', nullable: true })
pacApiKeyEncrypted: string;
@Column({ name: 'pac_sandbox', default: true })
pacSandbox: boolean;
// Configuracion
@Column({ length: 10, default: 'A' })
serie: string;
@Column({ name: 'folio_actual', default: 1 })
folioActual: number;
@Column({ name: 'auto_send_email', default: true })
autoSendEmail: boolean;
@Column({ name: 'logo_url', type: 'text', nullable: true })
logoUrl: string;
@Column({
type: 'varchar',
length: 20,
default: TaxConfigStatus.PENDING,
})
status: TaxConfigStatus;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,117 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
Request,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { InvoicesService } from './invoices.service';
import { CreateInvoiceDto } from './dto/create-invoice.dto';
import { TaxConfig } from './entities/tax-config.entity';
import { InvoiceStatus } from './entities/invoice.entity';
@ApiTags('Invoices')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('invoices')
export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) {}
// ==================== TAX CONFIG ====================
@Get('tax-config')
@ApiOperation({ summary: 'Obtener configuracion fiscal del tenant' })
async getTaxConfig(@Request() req): Promise<TaxConfig | null> {
return this.invoicesService.getTaxConfig(req.user.tenantId);
}
@Post('tax-config')
@ApiOperation({ summary: 'Guardar/actualizar configuracion fiscal' })
async saveTaxConfig(
@Request() req,
@Body() data: Partial<TaxConfig>,
): Promise<TaxConfig> {
return this.invoicesService.saveTaxConfig(req.user.tenantId, data);
}
// ==================== INVOICES ====================
@Post()
@ApiOperation({ summary: 'Crear nueva factura' })
async createInvoice(
@Request() req,
@Body() dto: CreateInvoiceDto,
) {
return this.invoicesService.createInvoice(req.user.tenantId, dto);
}
@Get()
@ApiOperation({ summary: 'Listar facturas del tenant' })
@ApiQuery({ name: 'status', required: false, enum: InvoiceStatus })
@ApiQuery({ name: 'from', required: false, type: String })
@ApiQuery({ name: 'to', required: false, type: String })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getInvoices(
@Request() req,
@Query('status') status?: InvoiceStatus,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('limit') limit?: number,
) {
return this.invoicesService.getInvoices(req.user.tenantId, {
status,
from: from ? new Date(from) : undefined,
to: to ? new Date(to) : undefined,
limit: limit ? Number(limit) : undefined,
});
}
@Get('summary')
@ApiOperation({ summary: 'Obtener resumen de facturacion del mes' })
@ApiQuery({ name: 'month', required: false, type: String, description: 'YYYY-MM-DD' })
async getSummary(
@Request() req,
@Query('month') month?: string,
) {
return this.invoicesService.getSummary(
req.user.tenantId,
month ? new Date(month) : undefined,
);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener factura por ID' })
async getInvoice(@Param('id', ParseUUIDPipe) id: string) {
return this.invoicesService.getInvoice(id);
}
@Post(':id/stamp')
@ApiOperation({ summary: 'Timbrar factura (enviar al SAT)' })
async stampInvoice(@Param('id', ParseUUIDPipe) id: string) {
return this.invoicesService.stampInvoice(id);
}
@Post(':id/cancel')
@ApiOperation({ summary: 'Cancelar factura' })
async cancelInvoice(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { reason: string; uuidReplacement?: string },
) {
return this.invoicesService.cancelInvoice(id, body.reason, body.uuidReplacement);
}
@Post(':id/send')
@ApiOperation({ summary: 'Enviar factura por email' })
async sendInvoice(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { email?: string },
) {
return this.invoicesService.sendInvoice(id, body.email);
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TaxConfig } from './entities/tax-config.entity';
import { Invoice } from './entities/invoice.entity';
import { InvoiceItem } from './entities/invoice-item.entity';
import { InvoicesService } from './invoices.service';
import { InvoicesController } from './invoices.controller';
@Module({
imports: [
TypeOrmModule.forFeature([TaxConfig, Invoice, InvoiceItem]),
],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
})
export class InvoicesModule {}

View File

@ -0,0 +1,252 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { TaxConfig, TaxConfigStatus } from './entities/tax-config.entity';
import { Invoice, InvoiceStatus, InvoiceType } from './entities/invoice.entity';
import { InvoiceItem } from './entities/invoice-item.entity';
import { CreateInvoiceDto } from './dto/create-invoice.dto';
@Injectable()
export class InvoicesService {
constructor(
@InjectRepository(TaxConfig)
private readonly taxConfigRepo: Repository<TaxConfig>,
@InjectRepository(Invoice)
private readonly invoiceRepo: Repository<Invoice>,
@InjectRepository(InvoiceItem)
private readonly itemRepo: Repository<InvoiceItem>,
private readonly dataSource: DataSource,
) {}
// ==================== TAX CONFIG ====================
async getTaxConfig(tenantId: string): Promise<TaxConfig | null> {
return this.taxConfigRepo.findOne({ where: { tenantId } });
}
async saveTaxConfig(
tenantId: string,
data: Partial<TaxConfig>,
): Promise<TaxConfig> {
let config = await this.getTaxConfig(tenantId);
if (config) {
Object.assign(config, data);
} else {
config = this.taxConfigRepo.create({ tenantId, ...data });
}
return this.taxConfigRepo.save(config);
}
// ==================== INVOICES ====================
async createInvoice(tenantId: string, dto: CreateInvoiceDto): Promise<Invoice> {
const taxConfig = await this.getTaxConfig(tenantId);
if (!taxConfig || taxConfig.status !== TaxConfigStatus.ACTIVE) {
throw new BadRequestException('Configuracion fiscal no activa');
}
// Calculate totals
let subtotal = 0;
let totalIva = 0;
for (const item of dto.items) {
const importe = item.cantidad * item.valorUnitario - (item.descuento || 0);
subtotal += importe;
// IVA 16%
totalIva += importe * 0.16;
}
const total = subtotal + totalIva;
// Get next folio
const folioResult = await this.dataSource.query(
`SELECT get_next_invoice_folio($1, $2) as folio`,
[tenantId, taxConfig.serie],
);
const folio = folioResult[0].folio;
// Create invoice
const invoice = this.invoiceRepo.create({
tenantId,
saleId: dto.saleId,
tipoComprobante: InvoiceType.INGRESO,
serie: taxConfig.serie,
folio,
receptorRfc: dto.receptorRfc.toUpperCase(),
receptorNombre: dto.receptorNombre,
receptorRegimenFiscal: dto.receptorRegimenFiscal,
receptorCodigoPostal: dto.receptorCodigoPostal,
receptorUsoCfdi: dto.receptorUsoCfdi,
receptorEmail: dto.receptorEmail,
subtotal,
totalImpuestosTrasladados: totalIva,
total,
formaPago: dto.formaPago,
metodoPago: dto.metodoPago,
condicionesPago: dto.condicionesPago,
status: InvoiceStatus.DRAFT,
notes: dto.notes,
});
await this.invoiceRepo.save(invoice);
// Create items
for (const itemDto of dto.items) {
const importe = itemDto.cantidad * itemDto.valorUnitario - (itemDto.descuento || 0);
const item = this.itemRepo.create({
invoiceId: invoice.id,
productId: itemDto.productId,
claveProdServ: itemDto.claveProdServ,
descripcion: itemDto.descripcion,
cantidad: itemDto.cantidad,
claveUnidad: itemDto.claveUnidad,
unidad: itemDto.unidad,
valorUnitario: itemDto.valorUnitario,
descuento: itemDto.descuento || 0,
importe,
});
await this.itemRepo.save(item);
}
return this.getInvoice(invoice.id);
}
async getInvoice(id: string): Promise<Invoice> {
const invoice = await this.invoiceRepo.findOne({
where: { id },
relations: ['items'],
});
if (!invoice) {
throw new NotFoundException('Factura no encontrada');
}
return invoice;
}
async getInvoices(
tenantId: string,
options?: {
status?: InvoiceStatus;
from?: Date;
to?: Date;
limit?: number;
},
): Promise<Invoice[]> {
const query = this.invoiceRepo.createQueryBuilder('invoice')
.where('invoice.tenant_id = :tenantId', { tenantId })
.leftJoinAndSelect('invoice.items', 'items')
.orderBy('invoice.created_at', 'DESC');
if (options?.status) {
query.andWhere('invoice.status = :status', { status: options.status });
}
if (options?.from) {
query.andWhere('invoice.created_at >= :from', { from: options.from });
}
if (options?.to) {
query.andWhere('invoice.created_at <= :to', { to: options.to });
}
if (options?.limit) {
query.limit(options.limit);
}
return query.getMany();
}
async stampInvoice(id: string): Promise<Invoice> {
const invoice = await this.getInvoice(id);
if (invoice.status !== InvoiceStatus.DRAFT && invoice.status !== InvoiceStatus.PENDING) {
throw new BadRequestException(`No se puede timbrar factura con estado: ${invoice.status}`);
}
const taxConfig = await this.getTaxConfig(invoice.tenantId);
if (!taxConfig) {
throw new BadRequestException('Configuracion fiscal no encontrada');
}
// In production, this would call the PAC API (Facturapi, etc.)
// For now, generate mock UUID
const mockUuid = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`.toUpperCase();
invoice.uuid = mockUuid;
invoice.status = InvoiceStatus.STAMPED;
invoice.stampedAt = new Date();
invoice.pacResponse = {
provider: taxConfig.pacProvider,
sandbox: taxConfig.pacSandbox,
timestamp: new Date().toISOString(),
};
return this.invoiceRepo.save(invoice);
}
async cancelInvoice(
id: string,
reason: string,
uuidReplacement?: string,
): Promise<Invoice> {
const invoice = await this.getInvoice(id);
if (invoice.status !== InvoiceStatus.STAMPED && invoice.status !== InvoiceStatus.SENT) {
throw new BadRequestException(`No se puede cancelar factura con estado: ${invoice.status}`);
}
// In production, this would call the PAC API to cancel
invoice.status = InvoiceStatus.CANCELLED;
invoice.cancelledAt = new Date();
invoice.cancelReason = reason;
invoice.cancelUuidReplacement = uuidReplacement;
return this.invoiceRepo.save(invoice);
}
async sendInvoice(id: string, email?: string): Promise<Invoice> {
const invoice = await this.getInvoice(id);
if (invoice.status !== InvoiceStatus.STAMPED) {
throw new BadRequestException('Solo se pueden enviar facturas timbradas');
}
const targetEmail = email || invoice.receptorEmail;
if (!targetEmail) {
throw new BadRequestException('No hay email de destino');
}
// In production, this would send the email with PDF and XML
invoice.status = InvoiceStatus.SENT;
return this.invoiceRepo.save(invoice);
}
// ==================== SUMMARY ====================
async getSummary(tenantId: string, month?: Date) {
const targetMonth = month || new Date();
const monthStr = targetMonth.toISOString().split('T')[0];
const result = await this.dataSource.query(
`SELECT * FROM get_invoice_summary($1, $2::date)`,
[tenantId, monthStr],
);
return result[0] || {
total_invoices: 0,
total_amount: 0,
total_cancelled: 0,
by_status: {},
};
}
}

View File

@ -0,0 +1,74 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsNumber,
IsOptional,
IsArray,
ValidateNested,
IsUUID,
Min,
IsDateString,
} from 'class-validator';
import { Type } from 'class-transformer';
export class SupplierOrderItemDto {
@ApiProperty({ description: 'ID del producto del proveedor' })
@IsUUID()
productId: string;
@ApiProperty({ example: 10, description: 'Cantidad a ordenar' })
@IsNumber()
@Min(1)
quantity: number;
@ApiProperty({ description: 'Notas para este item', required: false })
@IsString()
@IsOptional()
notes?: string;
}
export class CreateSupplierOrderDto {
@ApiProperty({ description: 'ID del proveedor' })
@IsUUID()
supplierId: string;
@ApiProperty({ type: [SupplierOrderItemDto], description: 'Items del pedido' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => SupplierOrderItemDto)
items: SupplierOrderItemDto[];
@ApiProperty({ description: 'Direccion de entrega' })
@IsString()
deliveryAddress: string;
@ApiProperty({ description: 'Ciudad', required: false })
@IsString()
@IsOptional()
deliveryCity?: string;
@ApiProperty({ description: 'Codigo postal', required: false })
@IsString()
@IsOptional()
deliveryZip?: string;
@ApiProperty({ description: 'Telefono de contacto', required: false })
@IsString()
@IsOptional()
deliveryPhone?: string;
@ApiProperty({ description: 'Nombre de contacto', required: false })
@IsString()
@IsOptional()
deliveryContact?: string;
@ApiProperty({ description: 'Fecha solicitada de entrega (YYYY-MM-DD)', required: false })
@IsDateString()
@IsOptional()
requestedDate?: string;
@ApiProperty({ description: 'Notas adicionales', required: false })
@IsString()
@IsOptional()
notes?: string;
}

View File

@ -0,0 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsNumber,
IsOptional,
IsUUID,
Min,
Max,
} from 'class-validator';
export class CreateSupplierReviewDto {
@ApiProperty({ description: 'ID del proveedor' })
@IsUUID()
supplierId: string;
@ApiProperty({ description: 'ID de la orden (opcional)', required: false })
@IsUUID()
@IsOptional()
orderId?: string;
@ApiProperty({ example: 5, description: 'Rating general (1-5)' })
@IsNumber()
@Min(1)
@Max(5)
rating: number;
@ApiProperty({ description: 'Titulo de la resena', required: false })
@IsString()
@IsOptional()
title?: string;
@ApiProperty({ description: 'Comentario', required: false })
@IsString()
@IsOptional()
comment?: string;
@ApiProperty({ description: 'Rating de calidad (1-5)', required: false })
@IsNumber()
@Min(1)
@Max(5)
@IsOptional()
ratingQuality?: number;
@ApiProperty({ description: 'Rating de entrega (1-5)', required: false })
@IsNumber()
@Min(1)
@Max(5)
@IsOptional()
ratingDelivery?: number;
@ApiProperty({ description: 'Rating de precio (1-5)', required: false })
@IsNumber()
@Min(1)
@Max(5)
@IsOptional()
ratingPrice?: number;
}

View File

@ -0,0 +1,30 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { Supplier } from './supplier.entity';
@Entity({ name: 'supplier_favorites', schema: 'marketplace' })
@Unique(['tenant_id', 'supplier_id'])
export class SupplierFavorites {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
tenant_id: string;
@Column('uuid')
supplier_id: string;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Supplier)
@JoinColumn({ name: 'supplier_id' })
supplier: Supplier;
}

View File

@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { SupplierOrder } from './supplier-order.entity';
import { SupplierProduct } from './supplier-product.entity';
@Entity({ schema: 'marketplace', name: 'supplier_order_items' })
export class SupplierOrderItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'order_id' })
orderId: string;
@Column({ name: 'product_id' })
productId: string;
@Column({ name: 'product_name', length: 300 })
productName: string;
@Column({ name: 'product_sku', length: 100, nullable: true })
productSku: string;
@Column({ type: 'int' })
quantity: number;
@Column({ name: 'unit_price', type: 'decimal', precision: 10, scale: 2 })
unitPrice: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
total: number;
@Column({ type: 'text', nullable: true })
notes: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => SupplierOrder, (order) => order.items)
@JoinColumn({ name: 'order_id' })
order: SupplierOrder;
@ManyToOne(() => SupplierProduct)
@JoinColumn({ name: 'product_id' })
product: SupplierProduct;
}

View File

@ -0,0 +1,112 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { Supplier } from './supplier.entity';
import { SupplierOrderItem } from './supplier-order-item.entity';
export enum SupplierOrderStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
PREPARING = 'preparing',
SHIPPED = 'shipped',
DELIVERED = 'delivered',
CANCELLED = 'cancelled',
REJECTED = 'rejected',
}
@Entity({ schema: 'marketplace', name: 'supplier_orders' })
export class SupplierOrder {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'supplier_id' })
supplierId: string;
@Column({ name: 'order_number', type: 'int', generated: 'increment' })
orderNumber: number;
@Column({
type: 'varchar',
length: 30,
default: SupplierOrderStatus.PENDING,
})
status: SupplierOrderStatus;
@Column({ type: 'decimal', precision: 12, scale: 2 })
subtotal: number;
@Column({ name: 'delivery_fee', type: 'decimal', precision: 10, scale: 2, default: 0 })
deliveryFee: number;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
discount: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
total: number;
@Column({ name: 'delivery_address', type: 'text' })
deliveryAddress: string;
@Column({ name: 'delivery_city', length: 100, nullable: true })
deliveryCity: string;
@Column({ name: 'delivery_zip', length: 10, nullable: true })
deliveryZip: string;
@Column({ name: 'delivery_phone', length: 20, nullable: true })
deliveryPhone: string;
@Column({ name: 'delivery_contact', length: 200, nullable: true })
deliveryContact: string;
@Column({ name: 'requested_date', type: 'date', nullable: true })
requestedDate: Date;
@Column({ name: 'confirmed_date', type: 'date', nullable: true })
confirmedDate: Date;
@Column({ name: 'estimated_delivery', type: 'timestamptz', nullable: true })
estimatedDelivery: Date;
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt: Date;
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'supplier_notes', type: 'text', nullable: true })
supplierNotes: string;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt: Date;
@Column({ name: 'cancel_reason', type: 'text', nullable: true })
cancelReason: string;
@Column({ name: 'cancelled_by', length: 20, nullable: true })
cancelledBy: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@ManyToOne(() => Supplier, (supplier) => supplier.orders)
@JoinColumn({ name: 'supplier_id' })
supplier: Supplier;
@OneToMany(() => SupplierOrderItem, (item) => item.order)
items: SupplierOrderItem[];
}

View File

@ -0,0 +1,72 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Supplier } from './supplier.entity';
@Entity({ schema: 'marketplace', name: 'supplier_products' })
export class SupplierProduct {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'supplier_id' })
supplierId: string;
@Column({ length: 300 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ length: 100, nullable: true })
sku: string;
@Column({ length: 50, nullable: true })
barcode: string;
@Column({ length: 100, nullable: true })
category: string;
@Column({ length: 100, nullable: true })
subcategory: string;
@Column({ name: 'image_url', type: 'text', nullable: true })
imageUrl: string;
@Column({ name: 'unit_price', type: 'decimal', precision: 10, scale: 2 })
unitPrice: number;
@Column({ name: 'unit_type', length: 50, default: 'pieza' })
unitType: string;
@Column({ name: 'min_quantity', default: 1 })
minQuantity: number;
@Column({ name: 'tiered_pricing', type: 'jsonb', default: '[]' })
tieredPricing: { min: number; price: number }[];
@Column({ name: 'in_stock', default: true })
inStock: boolean;
@Column({ name: 'stock_quantity', nullable: true })
stockQuantity: number;
@Column({ default: true })
active: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@ManyToOne(() => Supplier, (supplier) => supplier.products)
@JoinColumn({ name: 'supplier_id' })
supplier: Supplier;
}

View File

@ -0,0 +1,71 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Supplier } from './supplier.entity';
import { SupplierOrder } from './supplier-order.entity';
@Entity({ schema: 'marketplace', name: 'supplier_reviews' })
export class SupplierReview {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'supplier_id' })
supplierId: string;
@Column({ name: 'order_id', nullable: true })
orderId: string;
@Column({ type: 'int' })
rating: number;
@Column({ length: 200, nullable: true })
title: string;
@Column({ type: 'text', nullable: true })
comment: string;
@Column({ name: 'rating_quality', type: 'int', nullable: true })
ratingQuality: number;
@Column({ name: 'rating_delivery', type: 'int', nullable: true })
ratingDelivery: number;
@Column({ name: 'rating_price', type: 'int', nullable: true })
ratingPrice: number;
@Column({ name: 'supplier_response', type: 'text', nullable: true })
supplierResponse: string;
@Column({ name: 'responded_at', type: 'timestamptz', nullable: true })
respondedAt: Date;
@Column({ default: false })
verified: boolean;
@Column({ length: 20, default: 'active' })
status: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@ManyToOne(() => Supplier, (supplier) => supplier.reviews)
@JoinColumn({ name: 'supplier_id' })
supplier: Supplier;
@ManyToOne(() => SupplierOrder, { nullable: true })
@JoinColumn({ name: 'order_id' })
order: SupplierOrder;
}

View File

@ -0,0 +1,124 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { SupplierProduct } from './supplier-product.entity';
import { SupplierOrder } from './supplier-order.entity';
import { SupplierReview } from './supplier-review.entity';
export enum SupplierStatus {
PENDING = 'pending',
ACTIVE = 'active',
SUSPENDED = 'suspended',
INACTIVE = 'inactive',
}
@Entity({ schema: 'marketplace', name: 'suppliers' })
export class Supplier {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 200 })
name: string;
@Column({ name: 'legal_name', length: 300, nullable: true })
legalName: string;
@Column({ length: 13, nullable: true })
rfc: string;
@Column({ type: 'text', array: true, default: '{}' })
categories: string[];
@Column({ name: 'coverage_zones', type: 'text', array: true, default: '{}' })
coverageZones: string[];
@Column({ name: 'contact_name', length: 200, nullable: true })
contactName: string;
@Column({ name: 'contact_phone', length: 20, nullable: true })
contactPhone: string;
@Column({ name: 'contact_email', length: 200, nullable: true })
contactEmail: string;
@Column({ name: 'contact_whatsapp', length: 20, nullable: true })
contactWhatsapp: string;
@Column({ type: 'text', nullable: true })
address: string;
@Column({ length: 100, nullable: true })
city: string;
@Column({ length: 100, nullable: true })
state: string;
@Column({ name: 'zip_code', length: 10, nullable: true })
zipCode: string;
@Column({ name: 'logo_url', type: 'text', nullable: true })
logoUrl: string;
@Column({ name: 'banner_url', type: 'text', nullable: true })
bannerUrl: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ name: 'min_order_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
minOrderAmount: number;
@Column({ name: 'delivery_fee', type: 'decimal', precision: 10, scale: 2, default: 0 })
deliveryFee: number;
@Column({ name: 'free_delivery_min', type: 'decimal', precision: 10, scale: 2, nullable: true })
freeDeliveryMin: number;
@Column({ name: 'delivery_days', type: 'text', array: true, default: '{}' })
deliveryDays: string[];
@Column({ name: 'lead_time_days', default: 1 })
leadTimeDays: number;
@Column({ default: false })
verified: boolean;
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
verifiedAt: Date;
@Column({ type: 'decimal', precision: 2, scale: 1, default: 0 })
rating: number;
@Column({ name: 'total_reviews', default: 0 })
totalReviews: number;
@Column({ name: 'total_orders', default: 0 })
totalOrders: number;
@Column({ type: 'varchar', length: 20, default: SupplierStatus.PENDING })
status: SupplierStatus;
@Column({ name: 'user_id', nullable: true })
userId: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => SupplierProduct, (product) => product.supplier)
products: SupplierProduct[];
@OneToMany(() => SupplierOrder, (order) => order.supplier)
orders: SupplierOrder[];
@OneToMany(() => SupplierReview, (review) => review.supplier)
reviews: SupplierReview[];
}

View File

@ -0,0 +1,180 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
Request,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MarketplaceService } from './marketplace.service';
import { CreateSupplierOrderDto } from './dto/create-supplier-order.dto';
import { CreateSupplierReviewDto } from './dto/create-supplier-review.dto';
import { SupplierOrderStatus } from './entities/supplier-order.entity';
@ApiTags('Marketplace')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('marketplace')
export class MarketplaceController {
constructor(private readonly marketplaceService: MarketplaceService) {}
// ==================== SUPPLIERS ====================
@Get('suppliers')
@ApiOperation({ summary: 'Listar proveedores' })
@ApiQuery({ name: 'category', required: false })
@ApiQuery({ name: 'zipCode', required: false })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'limit', required: false, type: Number })
async findSuppliers(
@Query('category') category?: string,
@Query('zipCode') zipCode?: string,
@Query('search') search?: string,
@Query('limit') limit?: number,
) {
return this.marketplaceService.findSuppliers({
category,
zipCode,
search,
limit: limit ? Number(limit) : undefined,
});
}
@Get('suppliers/:id')
@ApiOperation({ summary: 'Obtener detalle de proveedor' })
async getSupplier(@Param('id', ParseUUIDPipe) id: string) {
return this.marketplaceService.getSupplier(id);
}
@Get('suppliers/:id/products')
@ApiOperation({ summary: 'Obtener productos de un proveedor' })
@ApiQuery({ name: 'category', required: false })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'inStock', required: false, type: Boolean })
async getSupplierProducts(
@Param('id', ParseUUIDPipe) id: string,
@Query('category') category?: string,
@Query('search') search?: string,
@Query('inStock') inStock?: boolean,
) {
return this.marketplaceService.getSupplierProducts(id, {
category,
search,
inStock,
});
}
@Get('suppliers/:id/reviews')
@ApiOperation({ summary: 'Obtener resenas de un proveedor' })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
async getSupplierReviews(
@Param('id', ParseUUIDPipe) id: string,
@Query('limit') limit?: number,
@Query('offset') offset?: number,
) {
return this.marketplaceService.getReviews(id, {
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
});
}
// ==================== ORDERS ====================
@Post('orders')
@ApiOperation({ summary: 'Crear pedido a proveedor' })
async createOrder(
@Request() req,
@Body() dto: CreateSupplierOrderDto,
) {
return this.marketplaceService.createOrder(req.user.tenantId, dto);
}
@Get('orders')
@ApiOperation({ summary: 'Listar mis pedidos' })
@ApiQuery({ name: 'status', required: false, enum: SupplierOrderStatus })
@ApiQuery({ name: 'supplierId', required: false })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getOrders(
@Request() req,
@Query('status') status?: SupplierOrderStatus,
@Query('supplierId') supplierId?: string,
@Query('limit') limit?: number,
) {
return this.marketplaceService.getOrders(req.user.tenantId, {
status,
supplierId,
limit: limit ? Number(limit) : undefined,
});
}
@Get('orders/:id')
@ApiOperation({ summary: 'Obtener detalle de pedido' })
async getOrder(@Param('id', ParseUUIDPipe) id: string) {
return this.marketplaceService.getOrder(id);
}
@Put('orders/:id/cancel')
@ApiOperation({ summary: 'Cancelar pedido' })
async cancelOrder(
@Request() req,
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { reason: string },
) {
return this.marketplaceService.cancelOrder(id, req.user.tenantId, body.reason);
}
// ==================== REVIEWS ====================
@Post('reviews')
@ApiOperation({ summary: 'Crear resena de proveedor' })
async createReview(
@Request() req,
@Body() dto: CreateSupplierReviewDto,
) {
return this.marketplaceService.createReview(req.user.tenantId, dto);
}
// ==================== FAVORITES ====================
@Get('favorites')
@ApiOperation({ summary: 'Obtener proveedores favoritos' })
async getFavorites(@Request() req) {
return this.marketplaceService.getFavorites(req.user.tenantId);
}
@Post('favorites/:supplierId')
@ApiOperation({ summary: 'Agregar proveedor a favoritos' })
async addFavorite(
@Request() req,
@Param('supplierId', ParseUUIDPipe) supplierId: string,
) {
await this.marketplaceService.addFavorite(req.user.tenantId, supplierId);
return { message: 'Agregado a favoritos' };
}
@Delete('favorites/:supplierId')
@ApiOperation({ summary: 'Quitar proveedor de favoritos' })
async removeFavorite(
@Request() req,
@Param('supplierId', ParseUUIDPipe) supplierId: string,
) {
await this.marketplaceService.removeFavorite(req.user.tenantId, supplierId);
return { message: 'Eliminado de favoritos' };
}
// ==================== STATS ====================
@Get('stats')
@ApiOperation({ summary: 'Obtener estadisticas del marketplace' })
async getStats() {
return this.marketplaceService.getMarketplaceStats();
}
}

View File

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Supplier } from './entities/supplier.entity';
import { SupplierProduct } from './entities/supplier-product.entity';
import { SupplierOrder } from './entities/supplier-order.entity';
import { SupplierOrderItem } from './entities/supplier-order-item.entity';
import { SupplierReview } from './entities/supplier-review.entity';
import { SupplierFavorites } from './entities/supplier-favorites.entity';
import { MarketplaceService } from './marketplace.service';
import { MarketplaceController } from './marketplace.controller';
@Module({
imports: [
TypeOrmModule.forFeature([
Supplier,
SupplierProduct,
SupplierOrder,
SupplierOrderItem,
SupplierReview,
SupplierFavorites,
]),
],
controllers: [MarketplaceController],
providers: [MarketplaceService],
exports: [MarketplaceService],
})
export class MarketplaceModule {}

View File

@ -0,0 +1,455 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Supplier, SupplierStatus } from './entities/supplier.entity';
import { SupplierProduct } from './entities/supplier-product.entity';
import { SupplierOrder, SupplierOrderStatus } from './entities/supplier-order.entity';
import { SupplierOrderItem } from './entities/supplier-order-item.entity';
import { SupplierReview } from './entities/supplier-review.entity';
import { CreateSupplierOrderDto } from './dto/create-supplier-order.dto';
import { CreateSupplierReviewDto } from './dto/create-supplier-review.dto';
@Injectable()
export class MarketplaceService {
constructor(
@InjectRepository(Supplier)
private readonly supplierRepo: Repository<Supplier>,
@InjectRepository(SupplierProduct)
private readonly productRepo: Repository<SupplierProduct>,
@InjectRepository(SupplierOrder)
private readonly orderRepo: Repository<SupplierOrder>,
@InjectRepository(SupplierOrderItem)
private readonly orderItemRepo: Repository<SupplierOrderItem>,
@InjectRepository(SupplierReview)
private readonly reviewRepo: Repository<SupplierReview>,
private readonly dataSource: DataSource,
) {}
// ==================== SUPPLIERS ====================
async findSuppliers(options?: {
category?: string;
zipCode?: string;
search?: string;
limit?: number;
}): Promise<Supplier[]> {
const query = this.supplierRepo.createQueryBuilder('supplier')
.where('supplier.status = :status', { status: SupplierStatus.ACTIVE })
.orderBy('supplier.rating', 'DESC')
.addOrderBy('supplier.total_orders', 'DESC');
if (options?.category) {
query.andWhere(':category = ANY(supplier.categories)', {
category: options.category,
});
}
if (options?.zipCode) {
query.andWhere(
'(supplier.coverage_zones = \'{}\' OR :zipCode = ANY(supplier.coverage_zones))',
{ zipCode: options.zipCode },
);
}
if (options?.search) {
query.andWhere(
'(supplier.name ILIKE :search OR supplier.description ILIKE :search)',
{ search: `%${options.search}%` },
);
}
if (options?.limit) {
query.limit(options.limit);
}
return query.getMany();
}
async getSupplier(id: string): Promise<Supplier> {
const supplier = await this.supplierRepo.findOne({
where: { id },
relations: ['products', 'reviews'],
});
if (!supplier) {
throw new NotFoundException('Proveedor no encontrado');
}
return supplier;
}
async getSupplierProducts(
supplierId: string,
options?: {
category?: string;
search?: string;
inStock?: boolean;
},
): Promise<SupplierProduct[]> {
const query = this.productRepo.createQueryBuilder('product')
.where('product.supplier_id = :supplierId', { supplierId })
.andWhere('product.active = true')
.orderBy('product.category', 'ASC')
.addOrderBy('product.name', 'ASC');
if (options?.category) {
query.andWhere('product.category = :category', { category: options.category });
}
if (options?.search) {
query.andWhere(
'(product.name ILIKE :search OR product.description ILIKE :search)',
{ search: `%${options.search}%` },
);
}
if (options?.inStock !== undefined) {
query.andWhere('product.in_stock = :inStock', { inStock: options.inStock });
}
return query.getMany();
}
// ==================== ORDERS ====================
async createOrder(
tenantId: string,
dto: CreateSupplierOrderDto,
): Promise<SupplierOrder> {
const supplier = await this.supplierRepo.findOne({
where: { id: dto.supplierId, status: SupplierStatus.ACTIVE },
});
if (!supplier) {
throw new NotFoundException('Proveedor no encontrado o no activo');
}
// Get products and calculate totals
const productIds = dto.items.map((item) => item.productId);
const products = await this.productRepo.findByIds(productIds);
if (products.length !== productIds.length) {
throw new BadRequestException('Algunos productos no fueron encontrados');
}
// Create product map for easy lookup
const productMap = new Map(products.map((p) => [p.id, p]));
// Validate min quantities and calculate subtotal
let subtotal = 0;
for (const item of dto.items) {
const product = productMap.get(item.productId);
if (!product) {
throw new BadRequestException(`Producto ${item.productId} no encontrado`);
}
if (item.quantity < product.minQuantity) {
throw new BadRequestException(
`Cantidad minima para ${product.name} es ${product.minQuantity}`,
);
}
if (!product.inStock) {
throw new BadRequestException(`${product.name} no esta disponible`);
}
// Calculate price based on tiered pricing
let unitPrice = Number(product.unitPrice);
if (product.tieredPricing && product.tieredPricing.length > 0) {
for (const tier of product.tieredPricing.sort((a, b) => b.min - a.min)) {
if (item.quantity >= tier.min) {
unitPrice = tier.price;
break;
}
}
}
subtotal += unitPrice * item.quantity;
}
// Calculate delivery fee
let deliveryFee = Number(supplier.deliveryFee);
if (supplier.freeDeliveryMin && subtotal >= Number(supplier.freeDeliveryMin)) {
deliveryFee = 0;
}
// Check minimum order
if (subtotal < Number(supplier.minOrderAmount)) {
throw new BadRequestException(
`Pedido minimo es $${supplier.minOrderAmount}`,
);
}
const total = subtotal + deliveryFee;
// Create order
const order = this.orderRepo.create({
tenantId,
supplierId: dto.supplierId,
status: SupplierOrderStatus.PENDING,
subtotal,
deliveryFee,
total,
deliveryAddress: dto.deliveryAddress,
deliveryCity: dto.deliveryCity,
deliveryZip: dto.deliveryZip,
deliveryPhone: dto.deliveryPhone,
deliveryContact: dto.deliveryContact,
requestedDate: dto.requestedDate ? new Date(dto.requestedDate) : null,
notes: dto.notes,
});
await this.orderRepo.save(order);
// Create order items
for (const item of dto.items) {
const product = productMap.get(item.productId);
let unitPrice = Number(product.unitPrice);
if (product.tieredPricing && product.tieredPricing.length > 0) {
for (const tier of product.tieredPricing.sort((a, b) => b.min - a.min)) {
if (item.quantity >= tier.min) {
unitPrice = tier.price;
break;
}
}
}
const orderItem = this.orderItemRepo.create({
orderId: order.id,
productId: item.productId,
productName: product.name,
productSku: product.sku,
quantity: item.quantity,
unitPrice,
total: unitPrice * item.quantity,
notes: item.notes,
});
await this.orderItemRepo.save(orderItem);
}
return this.getOrder(order.id);
}
async getOrder(id: string): Promise<SupplierOrder> {
const order = await this.orderRepo.findOne({
where: { id },
relations: ['items', 'supplier'],
});
if (!order) {
throw new NotFoundException('Pedido no encontrado');
}
return order;
}
async getOrders(
tenantId: string,
options?: {
status?: SupplierOrderStatus;
supplierId?: string;
limit?: number;
},
): Promise<SupplierOrder[]> {
const query = this.orderRepo.createQueryBuilder('order')
.where('order.tenant_id = :tenantId', { tenantId })
.leftJoinAndSelect('order.supplier', 'supplier')
.leftJoinAndSelect('order.items', 'items')
.orderBy('order.created_at', 'DESC');
if (options?.status) {
query.andWhere('order.status = :status', { status: options.status });
}
if (options?.supplierId) {
query.andWhere('order.supplier_id = :supplierId', { supplierId: options.supplierId });
}
if (options?.limit) {
query.limit(options.limit);
}
return query.getMany();
}
async updateOrderStatus(
id: string,
status: SupplierOrderStatus,
notes?: string,
): Promise<SupplierOrder> {
const order = await this.getOrder(id);
// Validate status transitions
const validTransitions: Record<SupplierOrderStatus, SupplierOrderStatus[]> = {
[SupplierOrderStatus.PENDING]: [SupplierOrderStatus.CONFIRMED, SupplierOrderStatus.CANCELLED, SupplierOrderStatus.REJECTED],
[SupplierOrderStatus.CONFIRMED]: [SupplierOrderStatus.PREPARING, SupplierOrderStatus.CANCELLED],
[SupplierOrderStatus.PREPARING]: [SupplierOrderStatus.SHIPPED, SupplierOrderStatus.CANCELLED],
[SupplierOrderStatus.SHIPPED]: [SupplierOrderStatus.DELIVERED, SupplierOrderStatus.CANCELLED],
[SupplierOrderStatus.DELIVERED]: [],
[SupplierOrderStatus.CANCELLED]: [],
[SupplierOrderStatus.REJECTED]: [],
};
if (!validTransitions[order.status].includes(status)) {
throw new BadRequestException(
`No se puede cambiar estado de ${order.status} a ${status}`,
);
}
order.status = status;
if (status === SupplierOrderStatus.CONFIRMED) {
order.confirmedDate = new Date();
}
if (status === SupplierOrderStatus.DELIVERED) {
order.deliveredAt = new Date();
}
if (status === SupplierOrderStatus.CANCELLED || status === SupplierOrderStatus.REJECTED) {
order.cancelledAt = new Date();
order.cancelReason = notes;
}
if (notes) {
order.supplierNotes = notes;
}
return this.orderRepo.save(order);
}
async cancelOrder(
id: string,
tenantId: string,
reason: string,
): Promise<SupplierOrder> {
const order = await this.getOrder(id);
if (order.tenantId !== tenantId) {
throw new BadRequestException('No autorizado');
}
if (![SupplierOrderStatus.PENDING, SupplierOrderStatus.CONFIRMED].includes(order.status)) {
throw new BadRequestException('No se puede cancelar el pedido en este estado');
}
order.status = SupplierOrderStatus.CANCELLED;
order.cancelledAt = new Date();
order.cancelReason = reason;
order.cancelledBy = 'tenant';
return this.orderRepo.save(order);
}
// ==================== REVIEWS ====================
async createReview(
tenantId: string,
dto: CreateSupplierReviewDto,
): Promise<SupplierReview> {
const supplier = await this.supplierRepo.findOne({
where: { id: dto.supplierId },
});
if (!supplier) {
throw new NotFoundException('Proveedor no encontrado');
}
// Check if order exists and belongs to tenant
let verified = false;
if (dto.orderId) {
const order = await this.orderRepo.findOne({
where: { id: dto.orderId, tenantId, supplierId: dto.supplierId },
});
if (!order) {
throw new BadRequestException('Orden no encontrada');
}
if (order.status === SupplierOrderStatus.DELIVERED) {
verified = true;
}
}
const review = this.reviewRepo.create({
tenantId,
supplierId: dto.supplierId,
orderId: dto.orderId,
rating: dto.rating,
title: dto.title,
comment: dto.comment,
ratingQuality: dto.ratingQuality,
ratingDelivery: dto.ratingDelivery,
ratingPrice: dto.ratingPrice,
verified,
});
return this.reviewRepo.save(review);
}
async getReviews(
supplierId: string,
options?: {
limit?: number;
offset?: number;
},
): Promise<SupplierReview[]> {
return this.reviewRepo.find({
where: { supplierId, status: 'active' },
order: { createdAt: 'DESC' },
take: options?.limit || 20,
skip: options?.offset || 0,
});
}
// ==================== FAVORITES ====================
async addFavorite(tenantId: string, supplierId: string): Promise<void> {
await this.dataSource.query(
`INSERT INTO marketplace.supplier_favorites (tenant_id, supplier_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[tenantId, supplierId],
);
}
async removeFavorite(tenantId: string, supplierId: string): Promise<void> {
await this.dataSource.query(
`DELETE FROM marketplace.supplier_favorites WHERE tenant_id = $1 AND supplier_id = $2`,
[tenantId, supplierId],
);
}
async getFavorites(tenantId: string): Promise<Supplier[]> {
const result = await this.dataSource.query(
`SELECT s.* FROM marketplace.suppliers s
JOIN marketplace.supplier_favorites f ON s.id = f.supplier_id
WHERE f.tenant_id = $1`,
[tenantId],
);
return result;
}
// ==================== STATS ====================
async getMarketplaceStats() {
const result = await this.dataSource.query(
`SELECT * FROM marketplace.get_marketplace_stats()`,
);
return result[0] || {
total_suppliers: 0,
active_suppliers: 0,
total_products: 0,
total_orders: 0,
total_gmv: 0,
avg_rating: 0,
};
}
}

View File

@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { Message } from './message.entity';
@Entity({ schema: 'messaging', name: 'conversations' })
export class Conversation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'phone_number', length: 20 })
phoneNumber: string;
@Column({ name: 'contact_name', length: 100, nullable: true })
contactName: string;
@Column({ name: 'conversation_type', length: 20 })
conversationType: string; // 'order', 'support', 'general'
@Column({ length: 20, default: 'active' })
status: string;
@Column({ name: 'last_message_at', type: 'timestamptz', nullable: true })
lastMessageAt: Date;
@Column({ name: 'last_message_preview', type: 'text', nullable: true })
lastMessagePreview: string;
@Column({ name: 'unread_count', default: 0 })
unreadCount: number;
@Column({ name: 'wa_conversation_id', length: 100, nullable: true })
waConversationId: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => Message, (message) => message.conversation)
messages: Message[];
}

View File

@ -0,0 +1,87 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Conversation } from './conversation.entity';
export enum MessageDirection {
INBOUND = 'inbound',
OUTBOUND = 'outbound',
}
export enum MessageType {
TEXT = 'text',
IMAGE = 'image',
AUDIO = 'audio',
DOCUMENT = 'document',
LOCATION = 'location',
INTERACTIVE = 'interactive',
TEMPLATE = 'template',
}
@Entity({ schema: 'messaging', name: 'messages' })
export class Message {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'conversation_id' })
conversationId: string;
@Column({
type: 'varchar',
length: 10,
})
direction: MessageDirection;
@Column({
name: 'message_type',
type: 'varchar',
length: 20,
})
messageType: MessageType;
@Column({ type: 'text', nullable: true })
content: string;
@Column({ name: 'media_url', type: 'text', nullable: true })
mediaUrl: string;
@Column({ name: 'media_mime_type', length: 50, nullable: true })
mediaMimeType: string;
@Column({ name: 'processed_by_llm', default: false })
processedByLlm: boolean;
@Column({ name: 'llm_response_id', nullable: true })
llmResponseId: string;
@Column({ name: 'tokens_used', nullable: true })
tokensUsed: number;
@Column({ name: 'wa_message_id', length: 100, nullable: true })
waMessageId: string;
@Column({ name: 'wa_status', length: 20, nullable: true })
waStatus: string; // 'sent', 'delivered', 'read', 'failed'
@Column({ name: 'wa_timestamp', type: 'timestamptz', nullable: true })
waTimestamp: Date;
@Column({ name: 'error_code', length: 20, nullable: true })
errorCode: string;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => Conversation, (conv) => conv.messages, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'conversation_id' })
conversation: Conversation;
}

View File

@ -0,0 +1,51 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
} from 'typeorm';
@Entity({ schema: 'messaging', name: 'notifications' })
export class Notification {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'user_id', nullable: true })
userId: string;
@Column({ name: 'notification_type', length: 50 })
notificationType: string; // 'order_new', 'low_stock', 'fiado_due', etc.
@Column({ type: 'text', array: true })
channels: string[]; // ['push', 'whatsapp', 'email']
@Column({ length: 100 })
title: string;
@Column({ type: 'text' })
body: string;
@Column({ type: 'jsonb', nullable: true })
data: Record<string, unknown>;
@Column({ name: 'push_sent', default: false })
pushSent: boolean;
@Column({ name: 'push_sent_at', type: 'timestamptz', nullable: true })
pushSentAt: Date;
@Column({ name: 'whatsapp_sent', default: false })
whatsappSent: boolean;
@Column({ name: 'whatsapp_sent_at', type: 'timestamptz', nullable: true })
whatsappSentAt: Date;
@Column({ name: 'read_at', type: 'timestamptz', nullable: true })
readAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@ -0,0 +1,74 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MessagingService } from './messaging.service';
@ApiTags('messaging')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/messaging')
export class MessagingController {
constructor(private readonly messagingService: MessagingService) {}
// ==================== CONVERSATIONS ====================
@Get('conversations')
@ApiOperation({ summary: 'Listar conversaciones' })
getConversations(@Request() req) {
return this.messagingService.getConversations(req.user.tenantId);
}
@Get('conversations/:id')
@ApiOperation({ summary: 'Obtener conversación con mensajes' })
getConversation(@Request() req, @Param('id') id: string) {
return this.messagingService.getConversation(req.user.tenantId, id);
}
@Get('conversations/:id/messages')
@ApiOperation({ summary: 'Obtener mensajes de una conversación' })
@ApiQuery({ name: 'limit', required: false })
getMessages(@Param('id') id: string, @Query('limit') limit?: number) {
return this.messagingService.getMessages(id, limit);
}
@Patch('conversations/:id/read')
@ApiOperation({ summary: 'Marcar conversación como leída' })
markAsRead(@Request() req, @Param('id') id: string) {
return this.messagingService.markAsRead(req.user.tenantId, id);
}
// ==================== NOTIFICATIONS ====================
@Get('notifications')
@ApiOperation({ summary: 'Listar notificaciones' })
@ApiQuery({ name: 'unreadOnly', required: false })
getNotifications(@Request() req, @Query('unreadOnly') unreadOnly?: boolean) {
return this.messagingService.getNotifications(
req.user.tenantId,
req.user.id,
unreadOnly,
);
}
@Get('notifications/count')
@ApiOperation({ summary: 'Contador de notificaciones no leídas' })
getUnreadCount(@Request() req) {
return this.messagingService.getUnreadCount(req.user.tenantId, req.user.id);
}
@Patch('notifications/:id/read')
@ApiOperation({ summary: 'Marcar notificación como leída' })
markNotificationRead(@Param('id') id: string) {
return this.messagingService.markNotificationRead(id);
}
}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MessagingController } from './messaging.controller';
import { MessagingService } from './messaging.service';
import { Conversation } from './entities/conversation.entity';
import { Message } from './entities/message.entity';
import { Notification } from './entities/notification.entity';
@Module({
imports: [TypeOrmModule.forFeature([Conversation, Message, Notification])],
controllers: [MessagingController],
providers: [MessagingService],
exports: [MessagingService],
})
export class MessagingModule {}

View File

@ -0,0 +1,181 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Conversation } from './entities/conversation.entity';
import { Message, MessageDirection, MessageType } from './entities/message.entity';
import { Notification } from './entities/notification.entity';
@Injectable()
export class MessagingService {
constructor(
@InjectRepository(Conversation)
private readonly conversationRepo: Repository<Conversation>,
@InjectRepository(Message)
private readonly messageRepo: Repository<Message>,
@InjectRepository(Notification)
private readonly notificationRepo: Repository<Notification>,
) {}
// ==================== CONVERSATIONS ====================
async getConversations(tenantId: string): Promise<Conversation[]> {
return this.conversationRepo.find({
where: { tenantId },
order: { lastMessageAt: 'DESC' },
});
}
async getConversation(tenantId: string, id: string): Promise<Conversation> {
const conversation = await this.conversationRepo.findOne({
where: { id, tenantId },
relations: ['messages'],
});
if (!conversation) {
throw new NotFoundException('Conversación no encontrada');
}
return conversation;
}
async findOrCreateConversation(
tenantId: string,
phoneNumber: string,
contactName?: string,
): Promise<Conversation> {
let conversation = await this.conversationRepo.findOne({
where: { tenantId, phoneNumber },
});
if (!conversation) {
conversation = this.conversationRepo.create({
tenantId,
phoneNumber,
contactName,
conversationType: 'general',
status: 'active',
});
await this.conversationRepo.save(conversation);
}
return conversation;
}
// ==================== MESSAGES ====================
async getMessages(conversationId: string, limit = 50): Promise<Message[]> {
return this.messageRepo.find({
where: { conversationId },
order: { createdAt: 'DESC' },
take: limit,
});
}
async addMessage(
conversationId: string,
direction: MessageDirection,
content: string,
type: MessageType = MessageType.TEXT,
metadata?: {
waMessageId?: string;
mediaUrl?: string;
mediaMimeType?: string;
},
): Promise<Message> {
const conversation = await this.conversationRepo.findOne({
where: { id: conversationId },
});
if (!conversation) {
throw new NotFoundException('Conversación no encontrada');
}
const message = this.messageRepo.create({
conversationId,
direction,
messageType: type,
content,
waMessageId: metadata?.waMessageId,
mediaUrl: metadata?.mediaUrl,
mediaMimeType: metadata?.mediaMimeType,
});
await this.messageRepo.save(message);
// Update conversation
conversation.lastMessageAt = new Date();
conversation.lastMessagePreview = content?.substring(0, 100);
if (direction === MessageDirection.INBOUND) {
conversation.unreadCount += 1;
}
await this.conversationRepo.save(conversation);
return message;
}
async markAsRead(tenantId: string, conversationId: string): Promise<Conversation> {
const conversation = await this.getConversation(tenantId, conversationId);
conversation.unreadCount = 0;
return this.conversationRepo.save(conversation);
}
// ==================== NOTIFICATIONS ====================
async getNotifications(tenantId: string, userId?: string, unreadOnly = false): Promise<Notification[]> {
const where: any = { tenantId };
if (userId) {
where.userId = userId;
}
if (unreadOnly) {
where.readAt = null;
}
return this.notificationRepo.find({
where,
order: { createdAt: 'DESC' },
take: 50,
});
}
async createNotification(
tenantId: string,
data: {
userId?: string;
notificationType: string;
channels: string[];
title: string;
body: string;
data?: Record<string, unknown>;
},
): Promise<Notification> {
const notification = this.notificationRepo.create({
tenantId,
...data,
});
return this.notificationRepo.save(notification);
}
async markNotificationRead(notificationId: string): Promise<Notification> {
const notification = await this.notificationRepo.findOne({
where: { id: notificationId },
});
if (!notification) {
throw new NotFoundException('Notificación no encontrada');
}
notification.readAt = new Date();
return this.notificationRepo.save(notification);
}
async getUnreadCount(tenantId: string, userId?: string): Promise<number> {
const where: any = { tenantId, readAt: null };
if (userId) {
where.userId = userId;
}
return this.notificationRepo.count({ where });
}
}

View File

@ -0,0 +1,112 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsNumber,
IsOptional,
IsUUID,
IsEnum,
IsArray,
ValidateNested,
Min,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { OrderChannel, OrderType, OrderStatus } from '../entities/order.entity';
export class OrderItemDto {
@ApiPropertyOptional({ description: 'ID del producto' })
@IsOptional()
@IsUUID()
productId?: string;
@ApiProperty({ description: 'Nombre del producto' })
@IsString()
@MaxLength(100)
productName: string;
@ApiProperty({ description: 'Cantidad', example: 2 })
@IsNumber()
@Min(0.001)
quantity: number;
@ApiProperty({ description: 'Precio unitario', example: 25.5 })
@IsNumber()
@Min(0)
unitPrice: number;
@ApiPropertyOptional({ description: 'Notas del item' })
@IsOptional()
@IsString()
notes?: string;
}
export class CreateOrderDto {
@ApiPropertyOptional({ description: 'ID del cliente' })
@IsOptional()
@IsUUID()
customerId?: string;
@ApiPropertyOptional({ enum: OrderChannel, default: OrderChannel.WHATSAPP })
@IsOptional()
@IsEnum(OrderChannel)
channel?: OrderChannel;
@ApiPropertyOptional({ enum: OrderType, default: OrderType.PICKUP })
@IsOptional()
@IsEnum(OrderType)
orderType?: OrderType;
@ApiProperty({ type: [OrderItemDto], description: 'Items del pedido' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
@ApiPropertyOptional({ description: 'Cargo por delivery', default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
deliveryFee?: number;
@ApiPropertyOptional({ description: 'Descuento', default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
discountAmount?: number;
@ApiPropertyOptional({ description: 'Dirección de entrega' })
@IsOptional()
@IsString()
deliveryAddress?: string;
@ApiPropertyOptional({ description: 'Notas de entrega' })
@IsOptional()
@IsString()
deliveryNotes?: string;
@ApiPropertyOptional({ description: 'Notas del cliente' })
@IsOptional()
@IsString()
customerNotes?: string;
@ApiPropertyOptional({ description: 'Método de pago' })
@IsOptional()
@IsString()
paymentMethod?: string;
}
export class UpdateOrderStatusDto {
@ApiProperty({ enum: OrderStatus, description: 'Nuevo estado' })
@IsEnum(OrderStatus)
status: OrderStatus;
@ApiPropertyOptional({ description: 'Razón (para cancelación)' })
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({ description: 'Notas internas' })
@IsOptional()
@IsString()
internalNotes?: string;
}

View File

@ -0,0 +1,49 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Order } from './order.entity';
import { Product } from '../../products/entities/product.entity';
@Entity({ schema: 'orders', name: 'order_items' })
export class OrderItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'order_id' })
orderId: string;
@Column({ name: 'product_id', nullable: true })
productId: string;
@Column({ name: 'product_name', length: 100 })
productName: string;
@Column({ type: 'decimal', precision: 10, scale: 3 })
quantity: number;
@Column({ name: 'unit_price', type: 'decimal', precision: 10, scale: 2 })
unitPrice: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
subtotal: number;
@Column({ type: 'text', nullable: true })
notes: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => Order, (order) => order.items, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'order_id' })
order: Order;
@ManyToOne(() => Product, { nullable: true })
@JoinColumn({ name: 'product_id' })
product: Product;
}

View File

@ -0,0 +1,137 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { OrderItem } from './order-item.entity';
import { Customer } from '../../customers/entities/customer.entity';
export enum OrderStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
PREPARING = 'preparing',
READY = 'ready',
DELIVERED = 'delivered',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
}
export enum OrderChannel {
WHATSAPP = 'whatsapp',
WEB = 'web',
APP = 'app',
POS = 'pos',
}
export enum OrderType {
PICKUP = 'pickup',
DELIVERY = 'delivery',
DINE_IN = 'dine_in',
}
@Entity({ schema: 'orders', name: 'orders' })
export class Order {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'customer_id', nullable: true })
customerId: string;
@Column({ name: 'order_number', length: 20 })
orderNumber: string;
@Column({
type: 'varchar',
length: 20,
default: OrderChannel.WHATSAPP,
})
channel: OrderChannel;
@Column({ type: 'decimal', precision: 10, scale: 2 })
subtotal: number;
@Column({ name: 'delivery_fee', type: 'decimal', precision: 10, scale: 2, default: 0 })
deliveryFee: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
discountAmount: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
total: number;
@Column({
name: 'order_type',
type: 'varchar',
length: 20,
default: OrderType.PICKUP,
})
orderType: OrderType;
@Column({ name: 'delivery_address', type: 'text', nullable: true })
deliveryAddress: string;
@Column({ name: 'delivery_notes', type: 'text', nullable: true })
deliveryNotes: string;
@Column({ name: 'estimated_delivery_at', type: 'timestamptz', nullable: true })
estimatedDeliveryAt: Date;
@Column({
type: 'varchar',
length: 20,
default: OrderStatus.PENDING,
})
status: OrderStatus;
@Column({ name: 'payment_status', length: 20, default: 'pending' })
paymentStatus: string;
@Column({ name: 'payment_method', length: 20, nullable: true })
paymentMethod: string;
@Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true })
confirmedAt: Date;
@Column({ name: 'preparing_at', type: 'timestamptz', nullable: true })
preparingAt: Date;
@Column({ name: 'ready_at', type: 'timestamptz', nullable: true })
readyAt: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt: Date;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt: Date;
@Column({ name: 'cancelled_reason', type: 'text', nullable: true })
cancelledReason: string;
@Column({ name: 'customer_notes', type: 'text', nullable: true })
customerNotes: string;
@Column({ name: 'internal_notes', type: 'text', nullable: true })
internalNotes: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => OrderItem, (item) => item.order, { cascade: true })
items: OrderItem[];
@ManyToOne(() => Customer, { nullable: true })
@JoinColumn({ name: 'customer_id' })
customer: Customer;
}

View File

@ -0,0 +1,122 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OrdersService } from './orders.service';
import { CreateOrderDto, UpdateOrderStatusDto } from './dto/order.dto';
import { OrderStatus } from './entities/order.entity';
@ApiTags('orders')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Get()
@ApiOperation({ summary: 'Listar pedidos' })
@ApiQuery({ name: 'status', enum: OrderStatus, required: false })
findAll(@Request() req, @Query('status') status?: OrderStatus) {
return this.ordersService.findAll(req.user.tenantId, status);
}
@Get('active')
@ApiOperation({ summary: 'Pedidos activos (pendientes, preparando, listos)' })
getActive(@Request() req) {
return this.ordersService.getActiveOrders(req.user.tenantId);
}
@Get('today')
@ApiOperation({ summary: 'Pedidos de hoy' })
getToday(@Request() req) {
return this.ordersService.getTodayOrders(req.user.tenantId);
}
@Get('stats')
@ApiOperation({ summary: 'Estadísticas de pedidos' })
getStats(@Request() req) {
return this.ordersService.getOrderStats(req.user.tenantId);
}
@Get('number/:orderNumber')
@ApiOperation({ summary: 'Buscar por número de pedido' })
findByNumber(@Request() req, @Param('orderNumber') orderNumber: string) {
return this.ordersService.findByOrderNumber(req.user.tenantId, orderNumber);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener pedido por ID' })
findOne(@Request() req, @Param('id') id: string) {
return this.ordersService.findOne(req.user.tenantId, id);
}
@Post()
@ApiOperation({ summary: 'Crear nuevo pedido' })
create(@Request() req, @Body() dto: CreateOrderDto) {
return this.ordersService.create(req.user.tenantId, dto);
}
@Patch(':id/status')
@ApiOperation({ summary: 'Actualizar estado del pedido' })
updateStatus(
@Request() req,
@Param('id') id: string,
@Body() dto: UpdateOrderStatusDto,
) {
return this.ordersService.updateStatus(req.user.tenantId, id, dto);
}
@Patch(':id/confirm')
@ApiOperation({ summary: 'Confirmar pedido' })
confirm(@Request() req, @Param('id') id: string) {
return this.ordersService.updateStatus(req.user.tenantId, id, {
status: OrderStatus.CONFIRMED,
});
}
@Patch(':id/prepare')
@ApiOperation({ summary: 'Marcar como preparando' })
prepare(@Request() req, @Param('id') id: string) {
return this.ordersService.updateStatus(req.user.tenantId, id, {
status: OrderStatus.PREPARING,
});
}
@Patch(':id/ready')
@ApiOperation({ summary: 'Marcar como listo' })
ready(@Request() req, @Param('id') id: string) {
return this.ordersService.updateStatus(req.user.tenantId, id, {
status: OrderStatus.READY,
});
}
@Patch(':id/complete')
@ApiOperation({ summary: 'Completar pedido' })
complete(@Request() req, @Param('id') id: string) {
return this.ordersService.updateStatus(req.user.tenantId, id, {
status: OrderStatus.COMPLETED,
});
}
@Patch(':id/cancel')
@ApiOperation({ summary: 'Cancelar pedido' })
cancel(
@Request() req,
@Param('id') id: string,
@Body('reason') reason?: string,
) {
return this.ordersService.updateStatus(req.user.tenantId, id, {
status: OrderStatus.CANCELLED,
reason,
});
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { Order } from './entities/order.entity';
import { OrderItem } from './entities/order-item.entity';
@Module({
imports: [TypeOrmModule.forFeature([Order, OrderItem])],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}

View File

@ -0,0 +1,224 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { Order, OrderStatus, OrderChannel } from './entities/order.entity';
import { OrderItem } from './entities/order-item.entity';
import { CreateOrderDto, UpdateOrderStatusDto } from './dto/order.dto';
@Injectable()
export class OrdersService {
constructor(
@InjectRepository(Order)
private readonly orderRepo: Repository<Order>,
@InjectRepository(OrderItem)
private readonly orderItemRepo: Repository<OrderItem>,
) {}
private generateOrderNumber(): string {
const now = new Date();
const dateStr = now.toISOString().slice(2, 10).replace(/-/g, '');
const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
return `P${dateStr}-${random}`;
}
async create(tenantId: string, dto: CreateOrderDto): Promise<Order> {
// Calculate totals
let subtotal = 0;
const items = dto.items.map((item) => {
const itemSubtotal = item.quantity * item.unitPrice;
subtotal += itemSubtotal;
return {
...item,
subtotal: itemSubtotal,
};
});
const deliveryFee = dto.deliveryFee || 0;
const discountAmount = dto.discountAmount || 0;
const total = subtotal + deliveryFee - discountAmount;
const order = this.orderRepo.create({
tenantId,
orderNumber: this.generateOrderNumber(),
customerId: dto.customerId,
channel: dto.channel || OrderChannel.WHATSAPP,
orderType: dto.orderType,
subtotal,
deliveryFee,
discountAmount,
total,
deliveryAddress: dto.deliveryAddress,
deliveryNotes: dto.deliveryNotes,
customerNotes: dto.customerNotes,
paymentMethod: dto.paymentMethod,
status: OrderStatus.PENDING,
items: items.map((item) => this.orderItemRepo.create(item)),
});
return this.orderRepo.save(order);
}
async findAll(tenantId: string, status?: OrderStatus): Promise<Order[]> {
const where: any = { tenantId };
if (status) {
where.status = status;
}
return this.orderRepo.find({
where,
relations: ['items', 'customer'],
order: { createdAt: 'DESC' },
});
}
async findOne(tenantId: string, id: string): Promise<Order> {
const order = await this.orderRepo.findOne({
where: { id, tenantId },
relations: ['items', 'customer'],
});
if (!order) {
throw new NotFoundException('Pedido no encontrado');
}
return order;
}
async findByOrderNumber(tenantId: string, orderNumber: string): Promise<Order> {
const order = await this.orderRepo.findOne({
where: { orderNumber, tenantId },
relations: ['items', 'customer'],
});
if (!order) {
throw new NotFoundException('Pedido no encontrado');
}
return order;
}
async getActiveOrders(tenantId: string): Promise<Order[]> {
return this.orderRepo.find({
where: [
{ tenantId, status: OrderStatus.PENDING },
{ tenantId, status: OrderStatus.CONFIRMED },
{ tenantId, status: OrderStatus.PREPARING },
{ tenantId, status: OrderStatus.READY },
],
relations: ['items', 'customer'],
order: { createdAt: 'ASC' },
});
}
async getTodayOrders(tenantId: string): Promise<Order[]> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
return this.orderRepo.find({
where: {
tenantId,
createdAt: Between(today, tomorrow),
},
relations: ['items', 'customer'],
order: { createdAt: 'DESC' },
});
}
async updateStatus(tenantId: string, id: string, dto: UpdateOrderStatusDto): Promise<Order> {
const order = await this.findOne(tenantId, id);
// Validate status transition
this.validateStatusTransition(order.status, dto.status);
order.status = dto.status;
// Update timestamps based on status
const now = new Date();
switch (dto.status) {
case OrderStatus.CONFIRMED:
order.confirmedAt = now;
break;
case OrderStatus.PREPARING:
order.preparingAt = now;
break;
case OrderStatus.READY:
order.readyAt = now;
break;
case OrderStatus.COMPLETED:
case OrderStatus.DELIVERED:
order.completedAt = now;
break;
case OrderStatus.CANCELLED:
order.cancelledAt = now;
order.cancelledReason = dto.reason;
break;
}
if (dto.internalNotes) {
order.internalNotes = dto.internalNotes;
}
return this.orderRepo.save(order);
}
private validateStatusTransition(currentStatus: OrderStatus, newStatus: OrderStatus): void {
const validTransitions: Record<OrderStatus, OrderStatus[]> = {
[OrderStatus.PENDING]: [OrderStatus.CONFIRMED, OrderStatus.CANCELLED],
[OrderStatus.CONFIRMED]: [OrderStatus.PREPARING, OrderStatus.CANCELLED],
[OrderStatus.PREPARING]: [OrderStatus.READY, OrderStatus.CANCELLED],
[OrderStatus.READY]: [OrderStatus.DELIVERED, OrderStatus.COMPLETED, OrderStatus.CANCELLED],
[OrderStatus.DELIVERED]: [OrderStatus.COMPLETED],
[OrderStatus.COMPLETED]: [],
[OrderStatus.CANCELLED]: [],
};
if (!validTransitions[currentStatus].includes(newStatus)) {
throw new BadRequestException(
`No se puede cambiar de ${currentStatus} a ${newStatus}`,
);
}
}
async getOrderStats(tenantId: string) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const [
todayOrders,
pendingCount,
preparingCount,
readyCount,
] = await Promise.all([
this.orderRepo.count({
where: { tenantId, createdAt: Between(today, tomorrow) },
}),
this.orderRepo.count({ where: { tenantId, status: OrderStatus.PENDING } }),
this.orderRepo.count({ where: { tenantId, status: OrderStatus.PREPARING } }),
this.orderRepo.count({ where: { tenantId, status: OrderStatus.READY } }),
]);
const todaySales = await this.orderRepo
.createQueryBuilder('order')
.select('SUM(order.total)', 'total')
.where('order.tenant_id = :tenantId', { tenantId })
.andWhere('order.created_at >= :today', { today })
.andWhere('order.created_at < :tomorrow', { tomorrow })
.andWhere('order.status NOT IN (:...statuses)', {
statuses: [OrderStatus.CANCELLED],
})
.getRawOne();
return {
todayOrders,
todaySales: Number(todaySales?.total) || 0,
pending: pendingCount,
preparing: preparingCount,
ready: readyCount,
activeTotal: pendingCount + preparingCount + readyCount,
};
}
}

View File

@ -0,0 +1,36 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
} from 'typeorm';
@Entity({ schema: 'sales', name: 'payments' })
export class PaymentMethod {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ length: 20 })
code: string;
@Column({ length: 100 })
name: string;
@Column({ length: 50, default: 'banknote' })
icon: string;
@Column({ name: 'is_default', default: false })
isDefault: boolean;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@Column({ name: 'sort_order', default: 0 })
sortOrder: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@ -0,0 +1,77 @@
import {
Controller,
Get,
Post,
Patch,
Param,
UseGuards,
Request,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('payments')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/payment-methods')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Get()
@ApiOperation({ summary: 'Listar métodos de pago' })
@ApiResponse({ status: 200, description: 'Lista de métodos de pago' })
async findAll(@Request() req: { user: { tenantId: string } }) {
return this.paymentsService.findAll(req.user.tenantId);
}
@Get('default')
@ApiOperation({ summary: 'Obtener método de pago por defecto' })
async getDefault(@Request() req: { user: { tenantId: string } }) {
return this.paymentsService.getDefault(req.user.tenantId);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener método de pago por ID' })
@ApiParam({ name: 'id', description: 'ID del método de pago' })
async findOne(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.paymentsService.findOne(req.user.tenantId, id);
}
@Post('initialize')
@ApiOperation({ summary: 'Inicializar métodos de pago por defecto' })
@ApiResponse({ status: 201, description: 'Métodos de pago inicializados' })
async initialize(@Request() req: { user: { tenantId: string } }) {
return this.paymentsService.initializeForTenant(req.user.tenantId);
}
@Patch(':id/toggle-active')
@ApiOperation({ summary: 'Activar/desactivar método de pago' })
@ApiParam({ name: 'id', description: 'ID del método de pago' })
async toggleActive(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.paymentsService.toggleActive(req.user.tenantId, id);
}
@Patch(':id/set-default')
@ApiOperation({ summary: 'Establecer como método de pago por defecto' })
@ApiParam({ name: 'id', description: 'ID del método de pago' })
async setDefault(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.paymentsService.setDefault(req.user.tenantId, id);
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { PaymentMethod } from './entities/payment-method.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([PaymentMethod]),
AuthModule,
],
controllers: [PaymentsController],
providers: [PaymentsService],
exports: [PaymentsService, TypeOrmModule],
})
export class PaymentsModule {}

View File

@ -0,0 +1,84 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PaymentMethod } from './entities/payment-method.entity';
const DEFAULT_PAYMENT_METHODS = [
{ code: 'cash', name: 'Efectivo', icon: 'banknote', isDefault: true, sortOrder: 1 },
{ code: 'card', name: 'Tarjeta', icon: 'credit-card', isDefault: false, sortOrder: 2 },
{ code: 'transfer', name: 'Transferencia', icon: 'smartphone', isDefault: false, sortOrder: 3 },
];
@Injectable()
export class PaymentsService {
constructor(
@InjectRepository(PaymentMethod)
private readonly paymentMethodRepository: Repository<PaymentMethod>,
) {}
async findAll(tenantId: string): Promise<PaymentMethod[]> {
return this.paymentMethodRepository.find({
where: { tenantId, isActive: true },
order: { sortOrder: 'ASC' },
});
}
async findOne(tenantId: string, id: string): Promise<PaymentMethod> {
const method = await this.paymentMethodRepository.findOne({
where: { id, tenantId },
});
if (!method) {
throw new NotFoundException('Método de pago no encontrado');
}
return method;
}
async getDefault(tenantId: string): Promise<PaymentMethod | null> {
return this.paymentMethodRepository.findOne({
where: { tenantId, isDefault: true, isActive: true },
});
}
async initializeForTenant(tenantId: string): Promise<PaymentMethod[]> {
const existing = await this.paymentMethodRepository.count({
where: { tenantId },
});
if (existing > 0) {
return this.findAll(tenantId);
}
const methods: PaymentMethod[] = [];
for (const method of DEFAULT_PAYMENT_METHODS) {
const paymentMethod = this.paymentMethodRepository.create({
...method,
tenantId,
});
methods.push(await this.paymentMethodRepository.save(paymentMethod));
}
return methods;
}
async toggleActive(tenantId: string, id: string): Promise<PaymentMethod> {
const method = await this.findOne(tenantId, id);
method.isActive = !method.isActive;
return this.paymentMethodRepository.save(method);
}
async setDefault(tenantId: string, id: string): Promise<PaymentMethod> {
// Remove default from all
await this.paymentMethodRepository.update(
{ tenantId },
{ isDefault: false },
);
// Set new default
const method = await this.findOne(tenantId, id);
method.isDefault = true;
return this.paymentMethodRepository.save(method);
}
}

View File

@ -0,0 +1,194 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
IsBoolean,
IsUUID,
Min,
MaxLength,
IsUrl,
} from 'class-validator';
export class CreateProductDto {
@ApiProperty({
description: 'Nombre del producto',
example: 'Coca-Cola 600ml',
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@ApiProperty({
description: 'Descripcion del producto',
required: false,
})
@IsOptional()
@IsString()
description?: string;
@ApiProperty({
description: 'Precio de venta',
example: 18.0,
minimum: 0,
})
@IsNumber()
@Min(0)
price: number;
@ApiProperty({
description: 'SKU unico (opcional)',
example: 'COCA-600',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(50)
sku?: string;
@ApiProperty({
description: 'Codigo de barras (opcional)',
example: '7501055306252',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(50)
barcode?: string;
@ApiProperty({
description: 'ID de la categoria',
required: false,
})
@IsOptional()
@IsUUID()
categoryId?: string;
@ApiProperty({
description: 'Costo de compra',
example: 12.0,
required: false,
})
@IsOptional()
@IsNumber()
@Min(0)
costPrice?: number;
@ApiProperty({
description: 'Precio de comparacion (antes)',
example: 20.0,
required: false,
})
@IsOptional()
@IsNumber()
@Min(0)
comparePrice?: number;
@ApiProperty({
description: 'Controlar inventario',
default: true,
})
@IsOptional()
@IsBoolean()
trackInventory?: boolean;
@ApiProperty({
description: 'Cantidad en stock inicial',
example: 100,
required: false,
})
@IsOptional()
@IsNumber()
stockQuantity?: number;
@ApiProperty({
description: 'Umbral de stock bajo',
example: 10,
required: false,
})
@IsOptional()
@IsNumber()
@Min(0)
lowStockThreshold?: number;
@ApiProperty({
description: 'Unidad de medida',
example: 'pieza',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(20)
unit?: string;
@ApiProperty({
description: 'URL de imagen',
required: false,
})
@IsOptional()
@IsString()
imageUrl?: string;
@ApiProperty({
description: 'Producto destacado',
default: false,
})
@IsOptional()
@IsBoolean()
isFeatured?: boolean;
}
export class UpdateProductDto extends PartialType(CreateProductDto) {
@ApiProperty({
description: 'Estado del producto',
example: 'active',
required: false,
})
@IsOptional()
@IsString()
status?: string;
}
export class ProductFilterDto {
@ApiProperty({
description: 'Filtrar por categoria',
required: false,
})
@IsOptional()
@IsUUID()
categoryId?: string;
@ApiProperty({
description: 'Buscar por nombre o SKU',
required: false,
})
@IsOptional()
@IsString()
search?: string;
@ApiProperty({
description: 'Solo favoritos/destacados',
required: false,
})
@IsOptional()
@IsBoolean()
favorites?: boolean;
@ApiProperty({
description: 'Solo activos',
default: true,
})
@IsOptional()
@IsBoolean()
active?: boolean;
@ApiProperty({
description: 'Solo con stock bajo',
required: false,
})
@IsOptional()
@IsBoolean()
lowStock?: boolean;
}

View File

@ -0,0 +1,75 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Category } from '../../categories/entities/category.entity';
@Entity({ schema: 'catalog', name: 'products' })
export class Product {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'category_id', nullable: true })
categoryId: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ length: 50, nullable: true })
sku: string;
@Column({ length: 50, nullable: true })
barcode: string;
@Column({ type: 'decimal', precision: 10, scale: 2 })
price: number;
@Column({ name: 'cost_price', type: 'decimal', precision: 10, scale: 2, nullable: true })
costPrice: number;
@Column({ name: 'compare_price', type: 'decimal', precision: 10, scale: 2, nullable: true })
comparePrice: number;
@Column({ name: 'track_inventory', default: true })
trackInventory: boolean;
@Column({ name: 'stock_quantity', type: 'int', default: 0 })
stockQuantity: number;
@Column({ name: 'low_stock_threshold', type: 'int', default: 5 })
lowStockThreshold: number;
@Column({ length: 20, default: 'pieza' })
unit: string;
@Column({ name: 'image_url', type: 'text', nullable: true })
imageUrl: string;
@Column({ length: 20, default: 'active' })
status: string;
@Column({ name: 'is_featured', default: false })
isFeatured: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@ManyToOne(() => Category, (category) => category.products, { nullable: true })
@JoinColumn({ name: 'category_id' })
category: Category;
}

View File

@ -0,0 +1,147 @@
import {
Controller,
Get,
Post,
Put,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { ProductsService } from './products.service';
import { CreateProductDto, UpdateProductDto, ProductFilterDto } from './dto/product.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('products')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
@ApiOperation({ summary: 'Listar productos' })
@ApiResponse({ status: 200, description: 'Lista de productos' })
async findAll(
@Request() req: { user: { tenantId: string } },
@Query() filters: ProductFilterDto,
) {
return this.productsService.findAll(req.user.tenantId, filters);
}
@Get('favorites')
@ApiOperation({ summary: 'Obtener productos favoritos' })
async getFavorites(@Request() req: { user: { tenantId: string } }) {
return this.productsService.findAll(req.user.tenantId, { favorites: true });
}
@Get('low-stock')
@ApiOperation({ summary: 'Obtener productos con stock bajo' })
async getLowStock(@Request() req: { user: { tenantId: string } }) {
return this.productsService.getLowStockProducts(req.user.tenantId);
}
@Get('barcode/:barcode')
@ApiOperation({ summary: 'Buscar producto por código de barras' })
@ApiParam({ name: 'barcode', description: 'Código de barras' })
async findByBarcode(
@Request() req: { user: { tenantId: string } },
@Param('barcode') barcode: string,
) {
return this.productsService.findByBarcode(req.user.tenantId, barcode);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener producto por ID' })
@ApiParam({ name: 'id', description: 'ID del producto' })
async findOne(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.productsService.findOne(req.user.tenantId, id);
}
@Post()
@ApiOperation({ summary: 'Crear producto' })
@ApiResponse({ status: 201, description: 'Producto creado' })
@ApiResponse({ status: 409, description: 'SKU o código de barras duplicado' })
@ApiResponse({ status: 400, description: 'Límite de productos alcanzado' })
async create(
@Request() req: { user: { tenantId: string } },
@Body() dto: CreateProductDto,
) {
return this.productsService.create(req.user.tenantId, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Actualizar producto' })
@ApiParam({ name: 'id', description: 'ID del producto' })
async update(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateProductDto,
) {
return this.productsService.update(req.user.tenantId, id, dto);
}
@Patch(':id/toggle-active')
@ApiOperation({ summary: 'Activar/desactivar producto' })
@ApiParam({ name: 'id', description: 'ID del producto' })
async toggleActive(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.productsService.toggleActive(req.user.tenantId, id);
}
@Patch(':id/toggle-favorite')
@ApiOperation({ summary: 'Marcar/desmarcar como favorito' })
@ApiParam({ name: 'id', description: 'ID del producto' })
async toggleFavorite(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.productsService.toggleFavorite(req.user.tenantId, id);
}
@Patch(':id/adjust-stock')
@ApiOperation({ summary: 'Ajustar stock manualmente' })
@ApiParam({ name: 'id', description: 'ID del producto' })
async adjustStock(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { adjustment: number; reason?: string },
) {
return this.productsService.adjustStock(
req.user.tenantId,
id,
body.adjustment,
body.reason,
);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Eliminar producto' })
@ApiParam({ name: 'id', description: 'ID del producto' })
async delete(
@Request() req: { user: { tenantId: string } },
@Param('id', ParseUUIDPipe) id: string,
) {
await this.productsService.delete(req.user.tenantId, id);
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
import { Product } from './entities/product.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Product]),
AuthModule,
],
controllers: [ProductsController],
providers: [ProductsService],
exports: [ProductsService, TypeOrmModule],
})
export class ProductsModule {}

View File

@ -0,0 +1,224 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './entities/product.entity';
import { Tenant } from '../auth/entities/tenant.entity';
import { CreateProductDto, UpdateProductDto, ProductFilterDto } from './dto/product.dto';
@Injectable()
export class ProductsService {
constructor(
@InjectRepository(Product)
private readonly productRepository: Repository<Product>,
@InjectRepository(Tenant)
private readonly tenantRepository: Repository<Tenant>,
) {}
async findAll(tenantId: string, filters: ProductFilterDto): Promise<Product[]> {
const query = this.productRepository
.createQueryBuilder('product')
.leftJoinAndSelect('product.category', 'category')
.where('product.tenantId = :tenantId', { tenantId });
if (filters.categoryId) {
query.andWhere('product.categoryId = :categoryId', {
categoryId: filters.categoryId,
});
}
if (filters.search) {
query.andWhere(
'(product.name ILIKE :search OR product.sku ILIKE :search OR product.barcode ILIKE :search)',
{ search: `%${filters.search}%` },
);
}
if (filters.favorites) {
query.andWhere('product.isFeatured = true');
}
if (filters.active !== false) {
query.andWhere("product.status = 'active'");
}
if (filters.lowStock) {
query.andWhere('product.trackInventory = true');
query.andWhere('product.stockQuantity <= product.lowStockThreshold');
}
query.orderBy('product.name', 'ASC');
return query.getMany();
}
async findOne(tenantId: string, id: string): Promise<Product> {
const product = await this.productRepository.findOne({
where: { id, tenantId },
relations: ['category'],
});
if (!product) {
throw new NotFoundException('Producto no encontrado');
}
return product;
}
async findByBarcode(tenantId: string, barcode: string): Promise<Product> {
const product = await this.productRepository.findOne({
where: { barcode, tenantId, status: 'active' },
relations: ['category'],
});
if (!product) {
throw new NotFoundException('Producto no encontrado');
}
return product;
}
async create(tenantId: string, dto: CreateProductDto): Promise<Product> {
// Check product limit
const tenant = await this.tenantRepository.findOne({
where: { id: tenantId },
});
if (!tenant) {
throw new BadRequestException('Tenant no encontrado');
}
// Product limit check - limits managed via subscription plans
const MAX_PRODUCTS_DEFAULT = 500;
const productCount = await this.productRepository.count({
where: { tenantId },
});
if (productCount >= MAX_PRODUCTS_DEFAULT) {
throw new BadRequestException(
`Has alcanzado el límite de ${MAX_PRODUCTS_DEFAULT} productos. Actualiza tu plan.`,
);
}
// Check SKU uniqueness
if (dto.sku) {
const existingSku = await this.productRepository.findOne({
where: { tenantId, sku: dto.sku },
});
if (existingSku) {
throw new ConflictException('Ya existe un producto con ese SKU');
}
}
// Check barcode uniqueness
if (dto.barcode) {
const existingBarcode = await this.productRepository.findOne({
where: { tenantId, barcode: dto.barcode },
});
if (existingBarcode) {
throw new ConflictException('Ya existe un producto con ese código de barras');
}
}
const product = this.productRepository.create({
...dto,
tenantId,
});
return this.productRepository.save(product);
}
async update(tenantId: string, id: string, dto: UpdateProductDto): Promise<Product> {
const product = await this.findOne(tenantId, id);
// Check SKU uniqueness if changed
if (dto.sku && dto.sku !== product.sku) {
const existingSku = await this.productRepository.findOne({
where: { tenantId, sku: dto.sku },
});
if (existingSku) {
throw new ConflictException('Ya existe un producto con ese SKU');
}
}
// Check barcode uniqueness if changed
if (dto.barcode && dto.barcode !== product.barcode) {
const existingBarcode = await this.productRepository.findOne({
where: { tenantId, barcode: dto.barcode },
});
if (existingBarcode) {
throw new ConflictException('Ya existe un producto con ese código de barras');
}
}
Object.assign(product, dto);
return this.productRepository.save(product);
}
async delete(tenantId: string, id: string): Promise<void> {
const product = await this.findOne(tenantId, id);
await this.productRepository.remove(product);
}
async toggleActive(tenantId: string, id: string): Promise<Product> {
const product = await this.findOne(tenantId, id);
product.status = product.status === 'active' ? 'inactive' : 'active';
return this.productRepository.save(product);
}
async toggleFavorite(tenantId: string, id: string): Promise<Product> {
const product = await this.findOne(tenantId, id);
product.isFeatured = !product.isFeatured;
return this.productRepository.save(product);
}
async adjustStock(
tenantId: string,
id: string,
adjustment: number,
reason?: string,
): Promise<Product> {
const product = await this.findOne(tenantId, id);
if (!product.trackInventory) {
throw new BadRequestException('Este producto no tiene control de inventario');
}
const newQuantity = Number(product.stockQuantity) + adjustment;
if (newQuantity < 0) {
throw new BadRequestException('No hay suficiente stock disponible');
}
product.stockQuantity = newQuantity;
return this.productRepository.save(product);
}
async getLowStockProducts(tenantId: string): Promise<Product[]> {
return this.productRepository
.createQueryBuilder('product')
.leftJoinAndSelect('product.category', 'category')
.where('product.tenantId = :tenantId', { tenantId })
.andWhere('product.trackInventory = true')
.andWhere('product.stockQuantity <= product.lowStockThreshold')
.andWhere("product.status = 'active'")
.orderBy('product.stockQuantity', 'ASC')
.getMany();
}
async getFavorites(tenantId: string): Promise<Product[]> {
return this.productRepository.find({
where: { tenantId, isFeatured: true, status: 'active' },
relations: ['category'],
order: { name: 'ASC' },
});
}
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Length, Matches } from 'class-validator';
export class ApplyCodeDto {
@ApiProperty({
example: 'MCH-ABC123',
description: 'Codigo de referido a aplicar',
})
@IsString()
@Length(3, 20)
@Matches(/^[A-Z0-9-]+$/, {
message: 'El codigo solo puede contener letras mayusculas, numeros y guiones',
})
code: string;
}

View File

@ -0,0 +1,31 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ schema: 'subscriptions', name: 'referral_codes' })
export class ReferralCode {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ length: 20, unique: true })
code: string;
@Column({ default: true })
active: boolean;
@Column({ name: 'uses_count', default: 0 })
usesCount: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,70 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Referral } from './referral.entity';
export enum RewardType {
FREE_MONTH = 'free_month',
DISCOUNT = 'discount',
}
export enum RewardStatus {
AVAILABLE = 'available',
USED = 'used',
EXPIRED = 'expired',
}
@Entity({ schema: 'subscriptions', name: 'referral_rewards' })
export class ReferralReward {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'referral_id' })
referralId: string;
@Column({
type: 'varchar',
length: 20,
default: RewardType.FREE_MONTH,
})
type: RewardType;
@Column({ name: 'months_earned', default: 0 })
monthsEarned: number;
@Column({ name: 'months_used', default: 0 })
monthsUsed: number;
@Column({ name: 'discount_percent', default: 0 })
discountPercent: number;
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt: Date;
@Column({
type: 'varchar',
length: 20,
default: RewardStatus.AVAILABLE,
})
status: RewardStatus;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@ManyToOne(() => Referral)
@JoinColumn({ name: 'referral_id' })
referral: Referral;
}

View File

@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export enum ReferralStatus {
PENDING = 'pending',
CONVERTED = 'converted',
REWARDED = 'rewarded',
EXPIRED = 'expired',
}
@Entity({ schema: 'subscriptions', name: 'referrals' })
export class Referral {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'referrer_tenant_id' })
referrerTenantId: string;
@Column({ name: 'referred_tenant_id', unique: true })
referredTenantId: string;
@Column({ name: 'code_used', length: 20 })
codeUsed: string;
@Column({
type: 'varchar',
length: 20,
default: ReferralStatus.PENDING,
})
status: ReferralStatus;
@Column({ name: 'referred_discount_applied', default: false })
referredDiscountApplied: boolean;
@Column({ name: 'referrer_reward_applied', default: false })
referrerRewardApplied: boolean;
@Column({ name: 'converted_at', type: 'timestamptz', nullable: true })
convertedAt: Date;
@Column({ name: 'reward_applied_at', type: 'timestamptz', nullable: true })
rewardAppliedAt: Date;
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,85 @@
import {
Controller,
Get,
Post,
Body,
Param,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ReferralsService } from './referrals.service';
import { ApplyCodeDto } from './dto/apply-code.dto';
@ApiTags('referrals')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/referrals')
export class ReferralsController {
constructor(private readonly referralsService: ReferralsService) {}
// ==================== CODES ====================
@Get('my-code')
@ApiOperation({ summary: 'Obtener mi codigo de referido' })
getMyCode(@Request() req) {
return this.referralsService.getMyCode(req.user.tenantId);
}
@Post('generate-code')
@ApiOperation({ summary: 'Generar nuevo codigo de referido' })
generateCode(@Request() req) {
return this.referralsService.generateCode(req.user.tenantId);
}
@Get('validate/:code')
@ApiOperation({ summary: 'Validar un codigo de referido' })
@ApiParam({ name: 'code', description: 'Codigo a validar' })
validateCode(@Param('code') code: string) {
return this.referralsService.validateCode(code);
}
// ==================== REFERRALS ====================
@Post('apply-code')
@ApiOperation({ summary: 'Aplicar codigo de referido (al registrarse)' })
applyCode(@Request() req, @Body() dto: ApplyCodeDto) {
return this.referralsService.applyCode(req.user.tenantId, dto.code);
}
@Get('list')
@ApiOperation({ summary: 'Listar mis referidos' })
getMyReferrals(@Request() req) {
return this.referralsService.getMyReferrals(req.user.tenantId);
}
@Get('stats')
@ApiOperation({ summary: 'Estadisticas de referidos' })
getStats(@Request() req) {
return this.referralsService.getStats(req.user.tenantId);
}
// ==================== REWARDS ====================
@Get('rewards')
@ApiOperation({ summary: 'Mis recompensas de referidos' })
getRewards(@Request() req) {
return this.referralsService.getMyRewards(req.user.tenantId);
}
@Get('rewards/available-months')
@ApiOperation({ summary: 'Meses gratis disponibles' })
getAvailableMonths(@Request() req) {
return this.referralsService.getAvailableMonths(req.user.tenantId);
}
// ==================== DISCOUNT ====================
@Get('discount')
@ApiOperation({ summary: 'Descuento disponible como referido' })
async getDiscount(@Request() req) {
const discount = await this.referralsService.getReferredDiscount(req.user.tenantId);
return { discountPercent: discount };
}
}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ReferralsController } from './referrals.controller';
import { ReferralsService } from './referrals.service';
import { ReferralCode } from './entities/referral-code.entity';
import { Referral } from './entities/referral.entity';
import { ReferralReward } from './entities/referral-reward.entity';
@Module({
imports: [TypeOrmModule.forFeature([ReferralCode, Referral, ReferralReward])],
controllers: [ReferralsController],
providers: [ReferralsService],
exports: [ReferralsService],
})
export class ReferralsModule {}

View File

@ -0,0 +1,266 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { ReferralCode } from './entities/referral-code.entity';
import { Referral, ReferralStatus } from './entities/referral.entity';
import { ReferralReward, RewardType, RewardStatus } from './entities/referral-reward.entity';
@Injectable()
export class ReferralsService {
constructor(
@InjectRepository(ReferralCode)
private readonly codeRepo: Repository<ReferralCode>,
@InjectRepository(Referral)
private readonly referralRepo: Repository<Referral>,
@InjectRepository(ReferralReward)
private readonly rewardRepo: Repository<ReferralReward>,
private readonly dataSource: DataSource,
) {}
// ==================== CODES ====================
async getMyCode(tenantId: string): Promise<ReferralCode> {
let code = await this.codeRepo.findOne({ where: { tenantId } });
if (!code) {
code = await this.generateCode(tenantId);
}
return code;
}
async generateCode(tenantId: string): Promise<ReferralCode> {
// Check if already has a code
const existing = await this.codeRepo.findOne({ where: { tenantId } });
if (existing) {
return existing;
}
// Generate unique code using database function
const result = await this.dataSource.query(
`SELECT generate_referral_code('MCH') as code`,
);
const newCode = result[0].code;
const referralCode = this.codeRepo.create({
tenantId,
code: newCode,
active: true,
});
return this.codeRepo.save(referralCode);
}
async validateCode(code: string): Promise<ReferralCode> {
const referralCode = await this.codeRepo.findOne({
where: { code: code.toUpperCase(), active: true },
});
if (!referralCode) {
throw new NotFoundException('Codigo de referido no valido o inactivo');
}
return referralCode;
}
// ==================== REFERRALS ====================
async applyCode(referredTenantId: string, code: string): Promise<Referral> {
// Validate code exists
const referralCode = await this.validateCode(code);
// Cannot refer yourself
if (referralCode.tenantId === referredTenantId) {
throw new BadRequestException('No puedes usar tu propio codigo de referido');
}
// Check if already referred
const existingReferral = await this.referralRepo.findOne({
where: { referredTenantId },
});
if (existingReferral) {
throw new ConflictException('Ya tienes un codigo de referido aplicado');
}
// Create referral with 30 day expiry
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
const referral = this.referralRepo.create({
referrerTenantId: referralCode.tenantId,
referredTenantId,
codeUsed: referralCode.code,
status: ReferralStatus.PENDING,
expiresAt,
});
await this.referralRepo.save(referral);
// Increment uses count
referralCode.usesCount += 1;
await this.codeRepo.save(referralCode);
return referral;
}
async getReferralByReferred(referredTenantId: string): Promise<Referral | null> {
return this.referralRepo.findOne({ where: { referredTenantId } });
}
async getMyReferrals(tenantId: string): Promise<Referral[]> {
return this.referralRepo.find({
where: { referrerTenantId: tenantId },
order: { createdAt: 'DESC' },
});
}
async convertReferral(referredTenantId: string): Promise<Referral> {
const referral = await this.referralRepo.findOne({
where: { referredTenantId, status: ReferralStatus.PENDING },
});
if (!referral) {
throw new NotFoundException('No se encontro referido pendiente');
}
// Check if expired
if (referral.expiresAt && new Date() > referral.expiresAt) {
referral.status = ReferralStatus.EXPIRED;
await this.referralRepo.save(referral);
throw new BadRequestException('El periodo de conversion ha expirado');
}
// Mark as converted
referral.status = ReferralStatus.CONVERTED;
referral.convertedAt = new Date();
await this.referralRepo.save(referral);
// Create reward for referrer (1 month free)
const expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1); // 1 year to use
const reward = this.rewardRepo.create({
tenantId: referral.referrerTenantId,
referralId: referral.id,
type: RewardType.FREE_MONTH,
monthsEarned: 1,
monthsUsed: 0,
expiresAt,
status: RewardStatus.AVAILABLE,
});
await this.rewardRepo.save(reward);
// Update referral as rewarded
referral.status = ReferralStatus.REWARDED;
referral.referrerRewardApplied = true;
referral.rewardAppliedAt = new Date();
await this.referralRepo.save(referral);
return referral;
}
// ==================== REWARDS ====================
async getMyRewards(tenantId: string): Promise<ReferralReward[]> {
return this.rewardRepo.find({
where: { tenantId },
order: { createdAt: 'DESC' },
});
}
async getAvailableMonths(tenantId: string): Promise<number> {
const rewards = await this.rewardRepo.find({
where: { tenantId, status: RewardStatus.AVAILABLE, type: RewardType.FREE_MONTH },
});
return rewards.reduce((sum, r) => sum + (r.monthsEarned - r.monthsUsed), 0);
}
async useReferralMonth(tenantId: string): Promise<boolean> {
// Find first available reward
const reward = await this.rewardRepo.findOne({
where: { tenantId, status: RewardStatus.AVAILABLE, type: RewardType.FREE_MONTH },
order: { createdAt: 'ASC' },
});
if (!reward || reward.monthsEarned <= reward.monthsUsed) {
return false;
}
reward.monthsUsed += 1;
if (reward.monthsUsed >= reward.monthsEarned) {
reward.status = RewardStatus.USED;
}
await this.rewardRepo.save(reward);
return true;
}
// ==================== STATS ====================
async getStats(tenantId: string) {
const result = await this.dataSource.query(
`SELECT * FROM get_referral_stats($1)`,
[tenantId],
);
const stats = result[0] || {
total_invited: 0,
total_converted: 0,
total_pending: 0,
total_expired: 0,
months_earned: 0,
months_available: 0,
};
const code = await this.getMyCode(tenantId);
return {
code: code.code,
totalInvited: stats.total_invited,
totalConverted: stats.total_converted,
totalPending: stats.total_pending,
totalExpired: stats.total_expired,
monthsEarned: stats.months_earned,
monthsAvailable: stats.months_available,
};
}
// ==================== DISCOUNT FOR REFERRED ====================
async getReferredDiscount(tenantId: string): Promise<number> {
const referral = await this.referralRepo.findOne({
where: {
referredTenantId: tenantId,
referredDiscountApplied: false,
status: ReferralStatus.PENDING,
},
});
if (!referral) {
return 0;
}
// 50% discount for first month
return 50;
}
async markDiscountApplied(tenantId: string): Promise<void> {
const referral = await this.referralRepo.findOne({
where: { referredTenantId: tenantId },
});
if (referral) {
referral.referredDiscountApplied = true;
await this.referralRepo.save(referral);
}
}
}

View File

@ -0,0 +1,161 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
IsUUID,
IsArray,
ValidateNested,
Min,
MaxLength,
Matches,
ArrayMinSize,
} from 'class-validator';
import { Type } from 'class-transformer';
export class SaleItemDto {
@ApiProperty({
description: 'ID del producto',
required: true,
})
@IsUUID()
@IsNotEmpty()
productId: string;
@ApiProperty({
description: 'Cantidad vendida',
example: 2,
minimum: 0.001,
})
@IsNumber()
@Min(0.001)
quantity: number;
@ApiProperty({
description: 'Descuento en porcentaje (opcional)',
example: 10,
required: false,
})
@IsOptional()
@IsNumber()
@Min(0)
discountPercent?: number;
}
export class CreateSaleDto {
@ApiProperty({
description: 'Lista de productos vendidos',
type: [SaleItemDto],
})
@IsArray()
@ArrayMinSize(1, { message: 'Debe incluir al menos un producto' })
@ValidateNested({ each: true })
@Type(() => SaleItemDto)
items: SaleItemDto[];
@ApiProperty({
description: 'ID del método de pago',
required: false,
})
@IsOptional()
@IsUUID()
paymentMethodId?: string;
@ApiProperty({
description: 'Monto recibido del cliente',
example: 100,
})
@IsNumber()
@Min(0)
amountReceived: number;
@ApiProperty({
description: 'Nombre del cliente (opcional)',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(200)
customerName?: string;
@ApiProperty({
description: 'Teléfono del cliente (opcional)',
required: false,
})
@IsOptional()
@IsString()
@Matches(/^[0-9]{10}$/, {
message: 'El teléfono debe tener exactamente 10 dígitos',
})
customerPhone?: string;
@ApiProperty({
description: 'Notas adicionales',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
@ApiProperty({
description: 'Información del dispositivo (auto-llenado)',
required: false,
})
@IsOptional()
deviceInfo?: Record<string, unknown>;
}
export class CancelSaleDto {
@ApiProperty({
description: 'Razón de la cancelación',
example: 'Cliente cambió de opinión',
})
@IsString()
@IsNotEmpty()
@MaxLength(255)
reason: string;
}
export class SalesFilterDto {
@ApiProperty({
description: 'Fecha de inicio (YYYY-MM-DD)',
required: false,
})
@IsOptional()
@IsString()
startDate?: string;
@ApiProperty({
description: 'Fecha de fin (YYYY-MM-DD)',
required: false,
})
@IsOptional()
@IsString()
endDate?: string;
@ApiProperty({
description: 'Estado de la venta',
required: false,
})
@IsOptional()
@IsString()
status?: string;
@ApiProperty({
description: 'Número de ticket',
required: false,
})
@IsOptional()
@IsString()
ticketNumber?: string;
@ApiProperty({
description: 'Límite de resultados',
required: false,
})
@IsOptional()
@IsNumber()
limit?: number;
}

Some files were not shown because too many files have changed in this diff Show More