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