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:
parent
bad656c8c7
commit
59e6ea4ab6
41
.dockerignore
Normal file
41
.dockerignore
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
89
Dockerfile
Normal file
89
Dockerfile
Normal 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
8
nest-cli.json
Normal 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
11845
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
91
package.json
Normal file
91
package.json
Normal 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
69
src/app.module.ts
Normal 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
80
src/main.ts
Normal 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();
|
||||
110
src/modules/auth/auth.controller.ts
Normal file
110
src/modules/auth/auth.controller.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
32
src/modules/auth/auth.module.ts
Normal file
32
src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
252
src/modules/auth/auth.service.ts
Normal file
252
src/modules/auth/auth.service.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
144
src/modules/auth/dto/register.dto.ts
Normal file
144
src/modules/auth/dto/register.dto.ts
Normal 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;
|
||||
}
|
||||
113
src/modules/auth/entities/tenant.entity.ts
Normal file
113
src/modules/auth/entities/tenant.entity.ts
Normal 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[];
|
||||
}
|
||||
78
src/modules/auth/entities/user.entity.ts
Normal file
78
src/modules/auth/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
16
src/modules/auth/guards/jwt-auth.guard.ts
Normal file
16
src/modules/auth/guards/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
39
src/modules/auth/strategies/jwt.strategy.ts
Normal file
39
src/modules/auth/strategies/jwt.strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
86
src/modules/billing/billing.controller.ts
Normal file
86
src/modules/billing/billing.controller.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/modules/billing/billing.module.ts
Normal file
27
src/modules/billing/billing.module.ts
Normal 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 {}
|
||||
299
src/modules/billing/billing.service.ts
Normal file
299
src/modules/billing/billing.service.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
223
src/modules/billing/stripe.service.ts
Normal file
223
src/modules/billing/stripe.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
119
src/modules/billing/webhooks.controller.ts
Normal file
119
src/modules/billing/webhooks.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
95
src/modules/categories/categories.controller.ts
Normal file
95
src/modules/categories/categories.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/modules/categories/categories.module.ts
Normal file
17
src/modules/categories/categories.module.ts
Normal 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 {}
|
||||
101
src/modules/categories/categories.service.ts
Normal file
101
src/modules/categories/categories.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
54
src/modules/categories/dto/category.dto.ts
Normal file
54
src/modules/categories/dto/category.dto.ts
Normal 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;
|
||||
}
|
||||
46
src/modules/categories/entities/category.entity.ts
Normal file
46
src/modules/categories/entities/category.entity.ts
Normal 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[];
|
||||
}
|
||||
111
src/modules/codi-spei/codi-spei.controller.ts
Normal file
111
src/modules/codi-spei/codi-spei.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/modules/codi-spei/codi-spei.module.ts
Normal file
17
src/modules/codi-spei/codi-spei.module.ts
Normal 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 {}
|
||||
263
src/modules/codi-spei/codi-spei.service.ts
Normal file
263
src/modules/codi-spei/codi-spei.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
31
src/modules/codi-spei/dto/generate-qr.dto.ts
Normal file
31
src/modules/codi-spei/dto/generate-qr.dto.ts
Normal 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;
|
||||
}
|
||||
66
src/modules/codi-spei/entities/codi-transaction.entity.ts
Normal file
66
src/modules/codi-spei/entities/codi-transaction.entity.ts
Normal 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;
|
||||
}
|
||||
82
src/modules/codi-spei/entities/spei-transaction.entity.ts
Normal file
82
src/modules/codi-spei/entities/spei-transaction.entity.ts
Normal 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;
|
||||
}
|
||||
47
src/modules/codi-spei/entities/virtual-account.entity.ts
Normal file
47
src/modules/codi-spei/entities/virtual-account.entity.ts
Normal 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;
|
||||
}
|
||||
107
src/modules/customers/customers.controller.ts
Normal file
107
src/modules/customers/customers.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/modules/customers/customers.module.ts
Normal file
15
src/modules/customers/customers.module.ts
Normal 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 {}
|
||||
231
src/modules/customers/customers.service.ts
Normal file
231
src/modules/customers/customers.service.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
103
src/modules/customers/dto/customer.dto.ts
Normal file
103
src/modules/customers/dto/customer.dto.ts
Normal 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;
|
||||
}
|
||||
76
src/modules/customers/entities/customer.entity.ts
Normal file
76
src/modules/customers/entities/customer.entity.ts
Normal 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[];
|
||||
}
|
||||
38
src/modules/customers/entities/fiado-payment.entity.ts
Normal file
38
src/modules/customers/entities/fiado-payment.entity.ts
Normal 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;
|
||||
}
|
||||
73
src/modules/customers/entities/fiado.entity.ts
Normal file
73
src/modules/customers/entities/fiado.entity.ts
Normal 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[];
|
||||
}
|
||||
263
src/modules/integrations/controllers/integrations.controller.ts
Normal file
263
src/modules/integrations/controllers/integrations.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
182
src/modules/integrations/dto/integration-credentials.dto.ts
Normal file
182
src/modules/integrations/dto/integration-credentials.dto.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
24
src/modules/integrations/integrations.module.ts
Normal file
24
src/modules/integrations/integrations.module.ts
Normal 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 {}
|
||||
424
src/modules/integrations/services/tenant-integrations.service.ts
Normal file
424
src/modules/integrations/services/tenant-integrations.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
64
src/modules/inventory/dto/inventory.dto.ts
Normal file
64
src/modules/inventory/dto/inventory.dto.ts
Normal 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;
|
||||
}
|
||||
69
src/modules/inventory/entities/inventory-movement.entity.ts
Normal file
69
src/modules/inventory/entities/inventory-movement.entity.ts
Normal 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;
|
||||
}
|
||||
64
src/modules/inventory/entities/stock-alert.entity.ts
Normal file
64
src/modules/inventory/entities/stock-alert.entity.ts
Normal 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;
|
||||
}
|
||||
91
src/modules/inventory/inventory.controller.ts
Normal file
91
src/modules/inventory/inventory.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/modules/inventory/inventory.module.ts
Normal file
15
src/modules/inventory/inventory.module.ts
Normal 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 {}
|
||||
232
src/modules/inventory/inventory.service.ts
Normal file
232
src/modules/inventory/inventory.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
120
src/modules/invoices/dto/create-invoice.dto.ts
Normal file
120
src/modules/invoices/dto/create-invoice.dto.ts
Normal 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;
|
||||
}
|
||||
64
src/modules/invoices/entities/invoice-item.entity.ts
Normal file
64
src/modules/invoices/entities/invoice-item.entity.ts
Normal 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;
|
||||
}
|
||||
152
src/modules/invoices/entities/invoice.entity.ts
Normal file
152
src/modules/invoices/entities/invoice.entity.ts
Normal 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[];
|
||||
}
|
||||
87
src/modules/invoices/entities/tax-config.entity.ts
Normal file
87
src/modules/invoices/entities/tax-config.entity.ts
Normal 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;
|
||||
}
|
||||
117
src/modules/invoices/invoices.controller.ts
Normal file
117
src/modules/invoices/invoices.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/modules/invoices/invoices.module.ts
Normal file
17
src/modules/invoices/invoices.module.ts
Normal 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 {}
|
||||
252
src/modules/invoices/invoices.service.ts
Normal file
252
src/modules/invoices/invoices.service.ts
Normal 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: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
74
src/modules/marketplace/dto/create-supplier-order.dto.ts
Normal file
74
src/modules/marketplace/dto/create-supplier-order.dto.ts
Normal 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;
|
||||
}
|
||||
57
src/modules/marketplace/dto/create-supplier-review.dto.ts
Normal file
57
src/modules/marketplace/dto/create-supplier-review.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
112
src/modules/marketplace/entities/supplier-order.entity.ts
Normal file
112
src/modules/marketplace/entities/supplier-order.entity.ts
Normal 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[];
|
||||
}
|
||||
72
src/modules/marketplace/entities/supplier-product.entity.ts
Normal file
72
src/modules/marketplace/entities/supplier-product.entity.ts
Normal 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;
|
||||
}
|
||||
71
src/modules/marketplace/entities/supplier-review.entity.ts
Normal file
71
src/modules/marketplace/entities/supplier-review.entity.ts
Normal 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;
|
||||
}
|
||||
124
src/modules/marketplace/entities/supplier.entity.ts
Normal file
124
src/modules/marketplace/entities/supplier.entity.ts
Normal 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[];
|
||||
}
|
||||
180
src/modules/marketplace/marketplace.controller.ts
Normal file
180
src/modules/marketplace/marketplace.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
27
src/modules/marketplace/marketplace.module.ts
Normal file
27
src/modules/marketplace/marketplace.module.ts
Normal 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 {}
|
||||
455
src/modules/marketplace/marketplace.service.ts
Normal file
455
src/modules/marketplace/marketplace.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
52
src/modules/messaging/entities/conversation.entity.ts
Normal file
52
src/modules/messaging/entities/conversation.entity.ts
Normal 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[];
|
||||
}
|
||||
87
src/modules/messaging/entities/message.entity.ts
Normal file
87
src/modules/messaging/entities/message.entity.ts
Normal 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;
|
||||
}
|
||||
51
src/modules/messaging/entities/notification.entity.ts
Normal file
51
src/modules/messaging/entities/notification.entity.ts
Normal 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;
|
||||
}
|
||||
74
src/modules/messaging/messaging.controller.ts
Normal file
74
src/modules/messaging/messaging.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/modules/messaging/messaging.module.ts
Normal file
15
src/modules/messaging/messaging.module.ts
Normal 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 {}
|
||||
181
src/modules/messaging/messaging.service.ts
Normal file
181
src/modules/messaging/messaging.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
112
src/modules/orders/dto/order.dto.ts
Normal file
112
src/modules/orders/dto/order.dto.ts
Normal 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;
|
||||
}
|
||||
49
src/modules/orders/entities/order-item.entity.ts
Normal file
49
src/modules/orders/entities/order-item.entity.ts
Normal 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;
|
||||
}
|
||||
137
src/modules/orders/entities/order.entity.ts
Normal file
137
src/modules/orders/entities/order.entity.ts
Normal 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;
|
||||
}
|
||||
122
src/modules/orders/orders.controller.ts
Normal file
122
src/modules/orders/orders.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
14
src/modules/orders/orders.module.ts
Normal file
14
src/modules/orders/orders.module.ts
Normal 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 {}
|
||||
224
src/modules/orders/orders.service.ts
Normal file
224
src/modules/orders/orders.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
36
src/modules/payments/entities/payment-method.entity.ts
Normal file
36
src/modules/payments/entities/payment-method.entity.ts
Normal 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;
|
||||
}
|
||||
77
src/modules/payments/payments.controller.ts
Normal file
77
src/modules/payments/payments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/modules/payments/payments.module.ts
Normal file
17
src/modules/payments/payments.module.ts
Normal 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 {}
|
||||
84
src/modules/payments/payments.service.ts
Normal file
84
src/modules/payments/payments.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
194
src/modules/products/dto/product.dto.ts
Normal file
194
src/modules/products/dto/product.dto.ts
Normal 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;
|
||||
}
|
||||
75
src/modules/products/entities/product.entity.ts
Normal file
75
src/modules/products/entities/product.entity.ts
Normal 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;
|
||||
}
|
||||
147
src/modules/products/products.controller.ts
Normal file
147
src/modules/products/products.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/modules/products/products.module.ts
Normal file
17
src/modules/products/products.module.ts
Normal 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 {}
|
||||
224
src/modules/products/products.service.ts
Normal file
224
src/modules/products/products.service.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
15
src/modules/referrals/dto/apply-code.dto.ts
Normal file
15
src/modules/referrals/dto/apply-code.dto.ts
Normal 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;
|
||||
}
|
||||
31
src/modules/referrals/entities/referral-code.entity.ts
Normal file
31
src/modules/referrals/entities/referral-code.entity.ts
Normal 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;
|
||||
}
|
||||
70
src/modules/referrals/entities/referral-reward.entity.ts
Normal file
70
src/modules/referrals/entities/referral-reward.entity.ts
Normal 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;
|
||||
}
|
||||
57
src/modules/referrals/entities/referral.entity.ts
Normal file
57
src/modules/referrals/entities/referral.entity.ts
Normal 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;
|
||||
}
|
||||
85
src/modules/referrals/referrals.controller.ts
Normal file
85
src/modules/referrals/referrals.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
15
src/modules/referrals/referrals.module.ts
Normal file
15
src/modules/referrals/referrals.module.ts
Normal 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 {}
|
||||
266
src/modules/referrals/referrals.service.ts
Normal file
266
src/modules/referrals/referrals.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
161
src/modules/sales/dto/sale.dto.ts
Normal file
161
src/modules/sales/dto/sale.dto.ts
Normal 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
Loading…
Reference in New Issue
Block a user