diff --git a/projects/erp-suite/apps/products/pos-micro/backend/.env.example b/projects/erp-suite/apps/products/pos-micro/backend/.env.example new file mode 100644 index 0000000..e7adef7 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/.env.example @@ -0,0 +1,38 @@ +# ============================================================================= +# POS MICRO - ENVIRONMENT CONFIGURATION +# ============================================================================= + +# Application +NODE_ENV=development +PORT=3000 +API_PREFIX=api/v1 + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_USERNAME=pos_micro +DB_PASSWORD=pos_micro_secret +DB_DATABASE=pos_micro_db +DB_SCHEMA=pos_micro +DB_SSL=false + +# JWT +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_EXPIRES_IN=24h +JWT_REFRESH_EXPIRES_IN=7d + +# Rate Limiting +THROTTLE_TTL=60 +THROTTLE_LIMIT=100 + +# WhatsApp Business API (optional) +WHATSAPP_API_URL=https://graph.facebook.com/v18.0 +WHATSAPP_TOKEN=your-whatsapp-token +WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id +WHATSAPP_VERIFY_TOKEN=your-verify-token + +# Logging +LOG_LEVEL=debug + +# CORS +CORS_ORIGIN=http://localhost:5173 diff --git a/projects/erp-suite/apps/products/pos-micro/backend/Dockerfile b/projects/erp-suite/apps/products/pos-micro/backend/Dockerfile new file mode 100644 index 0000000..a5043ad --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/Dockerfile @@ -0,0 +1,70 @@ +# ============================================================================= +# POS MICRO - Backend Dockerfile +# ============================================================================= + +# 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 + +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 + +USER nestjs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/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 . . + +# Expose port +EXPOSE 3000 + +# Start in development mode +CMD ["npm", "run", "start:dev"] diff --git a/projects/erp-suite/apps/products/pos-micro/backend/nest-cli.json b/projects/erp-suite/apps/products/pos-micro/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/package.json b/projects/erp-suite/apps/products/pos-micro/backend/package.json new file mode 100644 index 0000000..369ad45 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/package.json @@ -0,0 +1,83 @@ +{ + "name": "pos-micro-backend", + "version": "1.0.0", + "description": "POS Micro - Ultra-minimal point of sale backend", + "author": "ERP Suite", + "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": "^10.3.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.3.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/swagger": "^7.2.0", + "@nestjs/typeorm": "^10.0.1", + "bcrypt": "^5.1.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "helmet": "^7.1.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "pg": "^8.11.3", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1", + "typeorm": "^0.3.19", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@nestjs/schematics": "^10.1.0", + "@nestjs/testing": "^10.3.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" + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/app.module.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/app.module.ts new file mode 100644 index 0000000..460e441 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/app.module.ts @@ -0,0 +1,45 @@ +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'; + +@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', 'pos_micro'), + password: configService.get('DB_PASSWORD', 'pos_micro_secret'), + database: configService.get('DB_DATABASE', 'pos_micro_db'), + schema: configService.get('DB_SCHEMA', 'pos_micro'), + autoLoadEntities: true, + synchronize: configService.get('NODE_ENV') === 'development', + logging: configService.get('NODE_ENV') === 'development', + ssl: configService.get('DB_SSL') === 'true' ? { rejectUnauthorized: false } : false, + }), + }), + + // Feature Modules + AuthModule, + ProductsModule, + CategoriesModule, + SalesModule, + PaymentsModule, + ], +}) +export class AppModule {} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/main.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/main.ts new file mode 100644 index 0000000..a55d004 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/main.ts @@ -0,0 +1,81 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe, VersioningType } 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:5173'), + credentials: true, + }); + + // Global prefix + app.setGlobalPrefix(configService.get('API_PREFIX', 'api/v1')); + + // Versioning + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + }); + + // Validation + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + // Swagger Documentation + const config = new DocumentBuilder() + .setTitle('POS Micro API') + .setDescription( + 'API para punto de venta ultra-minimalista. Target: Puestos de calle, misceláneas, 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('payments', 'Métodos de pago') + .addTag('reports', 'Reportes y resúmenes') + .addTag('whatsapp', 'Integración WhatsApp') + .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(` +╔══════════════════════════════════════════════════════════════╗ +║ POS MICRO API ║ +╠══════════════════════════════════════════════════════════════╣ +║ Status: Running ║ +║ Port: ${port} ║ +║ Environment: ${configService.get('NODE_ENV', 'development')} ║ +║ Docs: http://localhost:${port}/docs ║ +╚══════════════════════════════════════════════════════════════╝ + `); +} + +bootstrap(); diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/auth.controller.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..6d2f2e4 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/auth.controller.ts @@ -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('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' }; + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/auth.module.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..8dd49e6 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/auth.service.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..e9db989 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,218 @@ +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, SubscriptionStatus } 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; +} + +export interface AuthResponse { + accessToken: string; + refreshToken: string; + user: { + id: string; + name: string; + isOwner: boolean; + }; + tenant: { + id: string; + businessName: string; + plan: string; + subscriptionStatus: SubscriptionStatus; + trialEndsAt: Date | null; + }; +} + +@Injectable() +export class AuthService { + constructor( + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async register(dto: RegisterDto): Promise { + // 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); + + // Calculate trial end date (14 days) + const trialEndsAt = new Date(); + trialEndsAt.setDate(trialEndsAt.getDate() + 14); + + // Create tenant + const tenant = this.tenantRepository.create({ + businessName: dto.businessName, + ownerName: dto.ownerName, + phone: dto.phone, + whatsapp: dto.whatsapp || dto.phone, + email: dto.email, + address: dto.address, + city: dto.city, + subscriptionStatus: SubscriptionStatus.TRIAL, + trialEndsAt, + }); + + const savedTenant = await this.tenantRepository.save(tenant); + + // Create user + const user = this.userRepository.create({ + tenantId: savedTenant.id, + name: dto.ownerName, + pinHash, + isOwner: true, + }); + + const savedUser = await this.userRepository.save(user); + + // Generate tokens + return this.generateTokens(savedUser, savedTenant); + } + + async login(dto: LoginDto): Promise { + // Find tenant by phone + const tenant = await this.tenantRepository.findOne({ + where: { phone: dto.phone }, + }); + + if (!tenant) { + throw new UnauthorizedException('Teléfono o PIN incorrectos'); + } + + // Check subscription status + if (tenant.subscriptionStatus === SubscriptionStatus.CANCELLED) { + throw new UnauthorizedException('Tu suscripción ha sido cancelada'); + } + + if (tenant.subscriptionStatus === SubscriptionStatus.SUSPENDED) { + throw new UnauthorizedException('Tu cuenta está suspendida. Contacta soporte.'); + } + + // Find user + const user = await this.userRepository.findOne({ + where: { tenantId: tenant.id }, + }); + + if (!user) { + throw new UnauthorizedException('Teléfono o PIN incorrectos'); + } + + // Verify PIN + const isValidPin = await bcrypt.compare(dto.pin, user.pinHash); + + if (!isValidPin) { + throw new UnauthorizedException('Teléfono o PIN incorrectos'); + } + + // Update last login + user.lastLoginAt = new Date(); + await this.userRepository.save(user); + + // Generate tokens + return this.generateTokens(user, tenant); + } + + async refreshToken(refreshToken: string): Promise { + try { + const payload = this.jwtService.verify(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 { + 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: tenant.phone, + }; + + 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, + isOwner: user.isOwner, + }, + tenant: { + id: tenant.id, + businessName: tenant.businessName, + plan: tenant.plan, + subscriptionStatus: tenant.subscriptionStatus, + trialEndsAt: tenant.trialEndsAt, + }, + }; + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/dto/register.dto.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/dto/register.dto.ts new file mode 100644 index 0000000..1e2766e --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/dto/register.dto.ts @@ -0,0 +1,134 @@ +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: 200, + }) + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(200) + businessName: string; + + @ApiProperty({ + description: 'Nombre del propietario', + example: 'Juan Pérez', + minLength: 2, + maxLength: 200, + }) + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(200) + ownerName: 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(100) + 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; +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/entities/tenant.entity.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/entities/tenant.entity.ts new file mode 100644 index 0000000..bada07d --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/entities/tenant.entity.ts @@ -0,0 +1,94 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { User } from './user.entity'; + +export enum SubscriptionStatus { + TRIAL = 'trial', + ACTIVE = 'active', + SUSPENDED = 'suspended', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'pos_micro', name: 'tenants' }) +export class Tenant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'business_name', length: 200 }) + businessName: string; + + @Column({ name: 'owner_name', length: 200 }) + ownerName: string; + + @Column({ length: 20 }) + phone: string; + + @Column({ length: 20, nullable: true }) + whatsapp: string; + + @Column({ length: 255, nullable: true }) + email: string; + + @Column({ type: 'text', nullable: true }) + address: string; + + @Column({ length: 100, nullable: true }) + city: string; + + @Column({ length: 50, default: 'México' }) + state: string; + + @Column({ length: 20, default: 'micro' }) + plan: string; + + @Column({ + name: 'subscription_status', + type: 'enum', + enum: SubscriptionStatus, + default: SubscriptionStatus.TRIAL, + }) + subscriptionStatus: SubscriptionStatus; + + @Column({ name: 'trial_ends_at', type: 'timestamp', nullable: true }) + trialEndsAt: Date; + + @Column({ name: 'subscription_ends_at', type: 'timestamp', nullable: true }) + subscriptionEndsAt: Date; + + @Column({ length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.0 }) + taxRate: number; + + @Column({ length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ type: 'jsonb', default: {} }) + settings: Record; + + @Column({ name: 'max_products', default: 500 }) + maxProducts: number; + + @Column({ name: 'max_sales_per_month', default: 1000 }) + maxSalesPerMonth: number; + + @Column({ name: 'current_month_sales', default: 0 }) + currentMonthSales: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => User, (user) => user.tenant) + users: User[]; +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/entities/user.entity.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/entities/user.entity.ts new file mode 100644 index 0000000..db7710f --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/entities/user.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from './tenant.entity'; + +@Entity({ schema: 'pos_micro', name: 'users' }) +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ name: 'pin_hash', length: 255 }) + pinHash: string; + + @Column({ name: 'password_hash', length: 255, nullable: true }) + passwordHash: string; + + @Column({ length: 200 }) + name: string; + + @Column({ name: 'is_owner', default: true }) + isOwner: boolean; + + @Column({ name: 'last_login_at', type: 'timestamp', nullable: true }) + lastLoginAt: Date; + + @Column({ name: 'last_login_device', type: 'jsonb', nullable: true }) + lastLoginDevice: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant, (tenant) => tenant.users) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/guards/jwt-auth.guard.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..0f4e152 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/guards/jwt-auth.guard.ts @@ -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(err: Error | null, user: TUser): TUser { + if (err || !user) { + throw err || new UnauthorizedException('Token inválido o expirado'); + } + return user; + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/strategies/jwt.strategy.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..00411db --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -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, + ) { + 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, + }; + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/categories.controller.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/categories.controller.ts new file mode 100644 index 0000000..b1f7195 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/categories.controller.ts @@ -0,0 +1,94 @@ +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, CreateCategoryDto, UpdateCategoryDto } from './categories.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('categories') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('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); + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/categories.module.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/categories.module.ts new file mode 100644 index 0000000..67734cf --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/categories.module.ts @@ -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 {} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/categories.service.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/categories.service.ts new file mode 100644 index 0000000..ec851da --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/categories.service.ts @@ -0,0 +1,115 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Category } from './entities/category.entity'; + +const MAX_CATEGORIES = 20; + +export class CreateCategoryDto { + name: string; + color?: string; + icon?: string; + sortOrder?: number; +} + +export class UpdateCategoryDto { + name?: string; + color?: string; + icon?: string; + sortOrder?: number; + isActive?: boolean; +} + +@Injectable() +export class CategoriesService { + constructor( + @InjectRepository(Category) + private readonly categoryRepository: Repository, + ) {} + + async findAll(tenantId: string, includeInactive = false): Promise { + const where: Record = { tenantId }; + + if (!includeInactive) { + where.isActive = true; + } + + return this.categoryRepository.find({ + where, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + } + + async findOne(tenantId: string, id: string): Promise { + 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 { + // 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 { + 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 { + const category = await this.findOne(tenantId, id); + await this.categoryRepository.remove(category); + } + + async toggleActive(tenantId: string, id: string): Promise { + const category = await this.findOne(tenantId, id); + category.isActive = !category.isActive; + return this.categoryRepository.save(category); + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/entities/category.entity.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/entities/category.entity.ts new file mode 100644 index 0000000..a3c1612 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/categories/entities/category.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { Product } from '../../products/entities/product.entity'; + +@Entity({ schema: 'pos_micro', name: 'categories' }) +export class Category { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ length: 100 }) + name: string; + + @Column({ length: 7, default: '#3B82F6' }) + color: string; + + @Column({ length: 50, default: 'package' }) + icon: string; + + @Column({ name: 'sort_order', default: 0 }) + sortOrder: number; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => Product, (product) => product.category) + products: Product[]; +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/entities/payment-method.entity.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/entities/payment-method.entity.ts new file mode 100644 index 0000000..72497db --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/entities/payment-method.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +@Entity({ schema: 'pos_micro', name: 'payment_methods' }) +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; +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/payments.controller.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/payments.controller.ts new file mode 100644 index 0000000..154bd4b --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/payments.controller.ts @@ -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('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); + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/payments.module.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/payments.module.ts new file mode 100644 index 0000000..99b529a --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/payments.module.ts @@ -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 {} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/payments.service.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/payments.service.ts new file mode 100644 index 0000000..ecdfc78 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/payments/payments.service.ts @@ -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, + ) {} + + async findAll(tenantId: string): Promise { + return this.paymentMethodRepository.find({ + where: { tenantId, isActive: true }, + order: { sortOrder: 'ASC' }, + }); + } + + async findOne(tenantId: string, id: string): Promise { + 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 { + return this.paymentMethodRepository.findOne({ + where: { tenantId, isDefault: true, isActive: true }, + }); + } + + async initializeForTenant(tenantId: string): Promise { + 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 { + const method = await this.findOne(tenantId, id); + method.isActive = !method.isActive; + return this.paymentMethodRepository.save(method); + } + + async setDefault(tenantId: string, id: string): Promise { + // 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); + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/dto/product.dto.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/dto/product.dto.ts new file mode 100644 index 0000000..dfd7aef --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/dto/product.dto.ts @@ -0,0 +1,179 @@ +import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsOptional, + IsNumber, + IsBoolean, + IsUUID, + Min, + MaxLength, + IsUrl, + Matches, +} from 'class-validator'; + +export class CreateProductDto { + @ApiProperty({ + description: 'Nombre del producto', + example: 'Taco de Pastor', + }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + name: string; + + @ApiProperty({ + description: 'Precio de venta', + example: 25.0, + minimum: 0, + }) + @IsNumber() + @Min(0) + price: number; + + @ApiProperty({ + description: 'SKU único (opcional)', + example: 'TACO-001', + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(50) + sku?: string; + + @ApiProperty({ + description: 'Código de barras (opcional)', + example: '7501234567890', + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(50) + barcode?: string; + + @ApiProperty({ + description: 'ID de la categoría', + required: false, + }) + @IsOptional() + @IsUUID() + categoryId?: string; + + @ApiProperty({ + description: 'Costo de compra', + example: 15.0, + required: false, + }) + @IsOptional() + @IsNumber() + @Min(0) + cost?: number; + + @ApiProperty({ + description: 'El precio ya incluye IVA', + default: true, + }) + @IsOptional() + @IsBoolean() + taxIncluded?: boolean; + + @ApiProperty({ + description: 'Controlar inventario', + default: false, + }) + @IsOptional() + @IsBoolean() + trackStock?: boolean; + + @ApiProperty({ + description: 'Cantidad en stock inicial', + example: 100, + required: false, + }) + @IsOptional() + @IsNumber() + stockQuantity?: number; + + @ApiProperty({ + description: 'Alerta de stock bajo', + example: 10, + required: false, + }) + @IsOptional() + @IsNumber() + @Min(0) + lowStockAlert?: number; + + @ApiProperty({ + description: 'URL de imagen', + required: false, + }) + @IsOptional() + @IsUrl() + @MaxLength(500) + imageUrl?: string; + + @ApiProperty({ + description: 'Color identificador (hex)', + example: '#FF5733', + required: false, + }) + @IsOptional() + @IsString() + @Matches(/^#[0-9A-Fa-f]{6}$/, { + message: 'El color debe ser un código hex válido (#RRGGBB)', + }) + color?: string; + + @ApiProperty({ + description: 'Marcar como favorito', + default: false, + }) + @IsOptional() + @IsBoolean() + isFavorite?: boolean; +} + +export class UpdateProductDto extends PartialType(CreateProductDto) {} + +export class ProductFilterDto { + @ApiProperty({ + description: 'Filtrar por categoría', + required: false, + }) + @IsOptional() + @IsUUID() + categoryId?: string; + + @ApiProperty({ + description: 'Buscar por nombre o SKU', + required: false, + }) + @IsOptional() + @IsString() + search?: string; + + @ApiProperty({ + description: 'Solo favoritos', + 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; +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/entities/product.entity.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/entities/product.entity.ts new file mode 100644 index 0000000..691c021 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/entities/product.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Category } from '../../categories/entities/category.entity'; + +@Entity({ schema: 'pos_micro', 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: 50, nullable: true }) + sku: string; + + @Column({ length: 50, nullable: true }) + barcode: string; + + @Column({ length: 200 }) + name: string; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + price: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + cost: number; + + @Column({ name: 'tax_included', default: true }) + taxIncluded: boolean; + + @Column({ name: 'track_stock', default: false }) + trackStock: boolean; + + @Column({ name: 'stock_quantity', type: 'decimal', precision: 12, scale: 3, default: 0 }) + stockQuantity: number; + + @Column({ name: 'low_stock_alert', default: 5 }) + lowStockAlert: number; + + @Column({ name: 'image_url', length: 500, nullable: true }) + imageUrl: string; + + @Column({ length: 7, nullable: true }) + color: string; + + @Column({ name: 'is_favorite', default: false }) + isFavorite: boolean; + + @Column({ name: 'sort_order', default: 0 }) + sortOrder: number; + + @Column({ name: 'is_active', default: true }) + isActive: 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; +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/products.controller.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/products.controller.ts new file mode 100644 index 0000000..43e5aba --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/products.controller.ts @@ -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('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); + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/products.module.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/products.module.ts new file mode 100644 index 0000000..47fe117 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/products.module.ts @@ -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 {} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/products.service.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/products.service.ts new file mode 100644 index 0000000..0f1adf6 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/products/products.service.ts @@ -0,0 +1,215 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, ILike } 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, + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + ) {} + + async findAll(tenantId: string, filters: ProductFilterDto): Promise { + 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.isFavorite = true'); + } + + if (filters.active !== false) { + query.andWhere('product.isActive = true'); + } + + if (filters.lowStock) { + query.andWhere('product.trackStock = true'); + query.andWhere('product.stockQuantity <= product.lowStockAlert'); + } + + query.orderBy('product.sortOrder', 'ASC'); + query.addOrderBy('product.name', 'ASC'); + + return query.getMany(); + } + + async findOne(tenantId: string, id: string): Promise { + 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 { + const product = await this.productRepository.findOne({ + where: { barcode, tenantId, isActive: true }, + relations: ['category'], + }); + + if (!product) { + throw new NotFoundException('Producto no encontrado'); + } + + return product; + } + + async create(tenantId: string, dto: CreateProductDto): Promise { + // Check product limit + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId }, + }); + + if (!tenant) { + throw new BadRequestException('Tenant no encontrado'); + } + + const productCount = await this.productRepository.count({ + where: { tenantId }, + }); + + if (productCount >= tenant.maxProducts) { + throw new BadRequestException( + `Has alcanzado el límite de ${tenant.maxProducts} 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 { + 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 { + const product = await this.findOne(tenantId, id); + await this.productRepository.remove(product); + } + + async toggleActive(tenantId: string, id: string): Promise { + const product = await this.findOne(tenantId, id); + product.isActive = !product.isActive; + return this.productRepository.save(product); + } + + async toggleFavorite(tenantId: string, id: string): Promise { + const product = await this.findOne(tenantId, id); + product.isFavorite = !product.isFavorite; + return this.productRepository.save(product); + } + + async adjustStock( + tenantId: string, + id: string, + adjustment: number, + reason?: string, + ): Promise { + const product = await this.findOne(tenantId, id); + + if (!product.trackStock) { + 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 { + return this.productRepository + .createQueryBuilder('product') + .leftJoinAndSelect('product.category', 'category') + .where('product.tenantId = :tenantId', { tenantId }) + .andWhere('product.trackStock = true') + .andWhere('product.stockQuantity <= product.lowStockAlert') + .andWhere('product.isActive = true') + .orderBy('product.stockQuantity', 'ASC') + .getMany(); + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/dto/sale.dto.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/dto/sale.dto.ts new file mode 100644 index 0000000..15f5c61 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/dto/sale.dto.ts @@ -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; +} + +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; +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/entities/sale-item.entity.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/entities/sale-item.entity.ts new file mode 100644 index 0000000..3d08728 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/entities/sale-item.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Sale } from './sale.entity'; +import { Product } from '../../products/entities/product.entity'; + +@Entity({ schema: 'pos_micro', name: 'sale_items' }) +export class SaleItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'sale_id' }) + saleId: string; + + @Column({ name: 'product_id', nullable: true }) + productId: string; + + @Column({ name: 'product_name', length: 200 }) + productName: string; + + @Column({ name: 'product_sku', length: 50, nullable: true }) + productSku: string; + + @Column({ type: 'decimal', precision: 12, scale: 3 }) + quantity: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 }) + unitPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Sale, (sale) => sale.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'sale_id' }) + sale: Sale; + + @ManyToOne(() => Product, { nullable: true }) + @JoinColumn({ name: 'product_id' }) + product: Product; +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/entities/sale.entity.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/entities/sale.entity.ts new file mode 100644 index 0000000..195ad41 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/entities/sale.entity.ts @@ -0,0 +1,86 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + OneToMany, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { SaleItem } from './sale-item.entity'; +import { PaymentMethod } from '../../payments/entities/payment-method.entity'; + +export enum SaleStatus { + COMPLETED = 'completed', + CANCELLED = 'cancelled', + REFUNDED = 'refunded', +} + +@Entity({ schema: 'pos_micro', name: 'sales' }) +export class Sale { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ name: 'ticket_number', length: 20 }) + ticketNumber: string; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; + + @Column({ name: 'payment_method_id', nullable: true }) + paymentMethodId: string; + + @Column({ name: 'amount_received', type: 'decimal', precision: 12, scale: 2 }) + amountReceived: number; + + @Column({ name: 'change_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + changeAmount: number; + + @Column({ + type: 'enum', + enum: SaleStatus, + default: SaleStatus.COMPLETED, + }) + status: SaleStatus; + + @Column({ name: 'cancelled_at', type: 'timestamp', nullable: true }) + cancelledAt: Date; + + @Column({ name: 'cancel_reason', length: 255, nullable: true }) + cancelReason: string; + + @Column({ name: 'customer_name', length: 200, nullable: true }) + customerName: string; + + @Column({ name: 'customer_phone', length: 20, nullable: true }) + customerPhone: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'device_info', type: 'jsonb', nullable: true }) + deviceInfo: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @OneToMany(() => SaleItem, (item) => item.sale, { cascade: true }) + items: SaleItem[]; + + @ManyToOne(() => PaymentMethod, { nullable: true }) + @JoinColumn({ name: 'payment_method_id' }) + paymentMethod: PaymentMethod; +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/sales.controller.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/sales.controller.ts new file mode 100644 index 0000000..cab5f04 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/sales.controller.ts @@ -0,0 +1,101 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + Request, + ParseUUIDPipe, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { SalesService } from './sales.service'; +import { CreateSaleDto, CancelSaleDto, SalesFilterDto } from './dto/sale.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('sales') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('sales') +export class SalesController { + constructor(private readonly salesService: SalesService) {} + + @Get() + @ApiOperation({ summary: 'Listar ventas' }) + @ApiResponse({ status: 200, description: 'Lista de ventas' }) + async findAll( + @Request() req: { user: { tenantId: string } }, + @Query() filters: SalesFilterDto, + ) { + return this.salesService.findAll(req.user.tenantId, filters); + } + + @Get('today') + @ApiOperation({ summary: 'Resumen de ventas del día' }) + async getTodaySummary(@Request() req: { user: { tenantId: string } }) { + return this.salesService.getTodaySummary(req.user.tenantId); + } + + @Get('recent') + @ApiOperation({ summary: 'Obtener ventas recientes' }) + async getRecentSales( + @Request() req: { user: { tenantId: string } }, + @Query('limit') limit?: number, + ) { + return this.salesService.getRecentSales(req.user.tenantId, limit || 10); + } + + @Get('ticket/:ticketNumber') + @ApiOperation({ summary: 'Buscar venta por número de ticket' }) + @ApiParam({ name: 'ticketNumber', description: 'Número de ticket' }) + async findByTicketNumber( + @Request() req: { user: { tenantId: string } }, + @Param('ticketNumber') ticketNumber: string, + ) { + return this.salesService.findByTicketNumber(req.user.tenantId, ticketNumber); + } + + @Get(':id') + @ApiOperation({ summary: 'Obtener venta por ID' }) + @ApiParam({ name: 'id', description: 'ID de la venta' }) + async findOne( + @Request() req: { user: { tenantId: string } }, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.salesService.findOne(req.user.tenantId, id); + } + + @Post() + @ApiOperation({ summary: 'Registrar nueva venta' }) + @ApiResponse({ status: 201, description: 'Venta creada exitosamente' }) + @ApiResponse({ status: 400, description: 'Stock insuficiente o límite alcanzado' }) + async create( + @Request() req: { user: { tenantId: string } }, + @Body() dto: CreateSaleDto, + ) { + return this.salesService.create(req.user.tenantId, dto); + } + + @Post(':id/cancel') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Cancelar venta' }) + @ApiParam({ name: 'id', description: 'ID de la venta' }) + @ApiResponse({ status: 200, description: 'Venta cancelada' }) + @ApiResponse({ status: 400, description: 'No se puede cancelar la venta' }) + async cancel( + @Request() req: { user: { tenantId: string } }, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CancelSaleDto, + ) { + return this.salesService.cancel(req.user.tenantId, id, dto); + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/sales.module.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/sales.module.ts new file mode 100644 index 0000000..1497688 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/sales.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SalesController } from './sales.controller'; +import { SalesService } from './sales.service'; +import { Sale } from './entities/sale.entity'; +import { SaleItem } from './entities/sale-item.entity'; +import { AuthModule } from '../auth/auth.module'; +import { ProductsModule } from '../products/products.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Sale, SaleItem]), + AuthModule, + ProductsModule, + ], + controllers: [SalesController], + providers: [SalesService], + exports: [SalesService, TypeOrmModule], +}) +export class SalesModule {} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/sales.service.ts b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/sales.service.ts new file mode 100644 index 0000000..024e9cb --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/src/modules/sales/sales.service.ts @@ -0,0 +1,270 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { Sale, SaleStatus } from './entities/sale.entity'; +import { SaleItem } from './entities/sale-item.entity'; +import { Product } from '../products/entities/product.entity'; +import { Tenant } from '../auth/entities/tenant.entity'; +import { CreateSaleDto, CancelSaleDto, SalesFilterDto } from './dto/sale.dto'; + +interface TodaySummary { + totalSales: number; + totalRevenue: number; + totalTax: number; + avgTicket: number; +} + +@Injectable() +export class SalesService { + constructor( + @InjectRepository(Sale) + private readonly saleRepository: Repository, + @InjectRepository(SaleItem) + private readonly saleItemRepository: Repository, + @InjectRepository(Product) + private readonly productRepository: Repository, + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + ) {} + + async findAll(tenantId: string, filters: SalesFilterDto): Promise { + const query = this.saleRepository + .createQueryBuilder('sale') + .leftJoinAndSelect('sale.items', 'items') + .leftJoinAndSelect('sale.paymentMethod', 'paymentMethod') + .where('sale.tenantId = :tenantId', { tenantId }); + + if (filters.startDate && filters.endDate) { + query.andWhere('DATE(sale.createdAt) BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }); + } else if (filters.startDate) { + query.andWhere('DATE(sale.createdAt) >= :startDate', { + startDate: filters.startDate, + }); + } else if (filters.endDate) { + query.andWhere('DATE(sale.createdAt) <= :endDate', { + endDate: filters.endDate, + }); + } + + if (filters.status) { + query.andWhere('sale.status = :status', { status: filters.status }); + } + + if (filters.ticketNumber) { + query.andWhere('sale.ticketNumber ILIKE :ticketNumber', { + ticketNumber: `%${filters.ticketNumber}%`, + }); + } + + query.orderBy('sale.createdAt', 'DESC'); + + if (filters.limit) { + query.limit(filters.limit); + } + + return query.getMany(); + } + + async findOne(tenantId: string, id: string): Promise { + const sale = await this.saleRepository.findOne({ + where: { id, tenantId }, + relations: ['items', 'paymentMethod'], + }); + + if (!sale) { + throw new NotFoundException('Venta no encontrada'); + } + + return sale; + } + + async findByTicketNumber(tenantId: string, ticketNumber: string): Promise { + const sale = await this.saleRepository.findOne({ + where: { ticketNumber, tenantId }, + relations: ['items', 'paymentMethod'], + }); + + if (!sale) { + throw new NotFoundException('Venta no encontrada'); + } + + return sale; + } + + async create(tenantId: string, dto: CreateSaleDto): Promise { + // Check monthly sales limit + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId }, + }); + + if (!tenant) { + throw new BadRequestException('Tenant no encontrado'); + } + + if (tenant.currentMonthSales >= tenant.maxSalesPerMonth) { + throw new BadRequestException( + `Has alcanzado el límite de ${tenant.maxSalesPerMonth} ventas este mes. Actualiza tu plan.`, + ); + } + + // Calculate totals + let subtotal = 0; + const saleItems: Partial[] = []; + + for (const item of dto.items) { + const product = await this.productRepository.findOne({ + where: { id: item.productId, tenantId, isActive: true }, + }); + + if (!product) { + throw new BadRequestException(`Producto ${item.productId} no encontrado`); + } + + // Check stock if tracking + if (product.trackStock && Number(product.stockQuantity) < item.quantity) { + throw new BadRequestException( + `Stock insuficiente para ${product.name}. Disponible: ${product.stockQuantity}`, + ); + } + + const itemSubtotal = + Number(product.price) * item.quantity * (1 - (item.discountPercent || 0) / 100); + + saleItems.push({ + productId: product.id, + productName: product.name, + productSku: product.sku, + quantity: item.quantity, + unitPrice: Number(product.price), + discountPercent: item.discountPercent || 0, + subtotal: itemSubtotal, + }); + + subtotal += itemSubtotal; + } + + // Calculate tax (assume 16% IVA included in price) + const taxRate = Number(tenant.taxRate) / 100; + const taxAmount = subtotal - subtotal / (1 + taxRate); + const total = subtotal; + + // Validate payment + if (dto.amountReceived < total) { + throw new BadRequestException( + `Monto recibido ($${dto.amountReceived}) es menor al total ($${total.toFixed(2)})`, + ); + } + + const changeAmount = dto.amountReceived - total; + + // Create sale + const sale = this.saleRepository.create({ + tenantId, + subtotal, + taxAmount, + discountAmount: 0, + total, + paymentMethodId: dto.paymentMethodId, + amountReceived: dto.amountReceived, + changeAmount, + customerName: dto.customerName, + customerPhone: dto.customerPhone, + notes: dto.notes, + deviceInfo: dto.deviceInfo, + status: SaleStatus.COMPLETED, + }); + + const savedSale = await this.saleRepository.save(sale); + + // Create sale items + for (const item of saleItems) { + const saleItem = this.saleItemRepository.create({ + ...item, + saleId: savedSale.id, + }); + await this.saleItemRepository.save(saleItem); + } + + // Return complete sale with items + return this.findOne(tenantId, savedSale.id); + } + + async cancel(tenantId: string, id: string, dto: CancelSaleDto): Promise { + const sale = await this.findOne(tenantId, id); + + if (sale.status !== SaleStatus.COMPLETED) { + throw new BadRequestException('Solo se pueden cancelar ventas completadas'); + } + + // Check if sale is from today (can only cancel same-day sales) + const today = new Date(); + today.setHours(0, 0, 0, 0); + const saleDate = new Date(sale.createdAt); + saleDate.setHours(0, 0, 0, 0); + + if (saleDate.getTime() !== today.getTime()) { + throw new BadRequestException('Solo se pueden cancelar ventas del día actual'); + } + + sale.status = SaleStatus.CANCELLED; + sale.cancelledAt = new Date(); + sale.cancelReason = dto.reason; + + // Restore stock + for (const item of sale.items) { + if (item.productId) { + const product = await this.productRepository.findOne({ + where: { id: item.productId }, + }); + + if (product?.trackStock) { + product.stockQuantity = Number(product.stockQuantity) + Number(item.quantity); + await this.productRepository.save(product); + } + } + } + + return this.saleRepository.save(sale); + } + + async getTodaySummary(tenantId: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const result = await this.saleRepository + .createQueryBuilder('sale') + .select([ + 'COUNT(sale.id) as totalSales', + 'COALESCE(SUM(sale.total), 0) as totalRevenue', + 'COALESCE(SUM(sale.taxAmount), 0) as totalTax', + 'COALESCE(AVG(sale.total), 0) as avgTicket', + ]) + .where('sale.tenantId = :tenantId', { tenantId }) + .andWhere('DATE(sale.createdAt) = CURRENT_DATE') + .andWhere('sale.status = :status', { status: SaleStatus.COMPLETED }) + .getRawOne(); + + return { + totalSales: parseInt(result.totalsales, 10) || 0, + totalRevenue: parseFloat(result.totalrevenue) || 0, + totalTax: parseFloat(result.totaltax) || 0, + avgTicket: parseFloat(result.avgticket) || 0, + }; + } + + async getRecentSales(tenantId: string, limit = 10): Promise { + return this.saleRepository.find({ + where: { tenantId }, + relations: ['items', 'paymentMethod'], + order: { createdAt: 'DESC' }, + take: limit, + }); + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/backend/tsconfig.json b/projects/erp-suite/apps/products/pos-micro/backend/tsconfig.json new file mode 100644 index 0000000..a1e26cd --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/backend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["src/*"], + "@modules/*": ["src/modules/*"], + "@common/*": ["src/common/*"], + "@config/*": ["src/config/*"] + } + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/database/ddl/00-schema.sql b/projects/erp-suite/apps/products/pos-micro/database/ddl/00-schema.sql new file mode 100644 index 0000000..6c9a16e --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/database/ddl/00-schema.sql @@ -0,0 +1,596 @@ +-- ============================================================================ +-- POS MICRO - DATABASE SCHEMA +-- ============================================================================ +-- Version: 1.0.0 +-- Description: Ultra-minimal schema for street vendors and small shops +-- Target: ~10 tables, 100 MXN/month SaaS +-- Market: Mexican informal economy +-- Execute: Creates complete database for POS Micro +-- ============================================================================ + +-- Enable required extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ============================================================================ +-- SCHEMA CREATION +-- ============================================================================ + +CREATE SCHEMA IF NOT EXISTS pos_micro; + +-- Set search path +SET search_path TO pos_micro, public; + +-- ============================================================================ +-- UTILITY FUNCTIONS +-- ============================================================================ + +-- Function: Update updated_at timestamp +CREATE OR REPLACE FUNCTION pos_micro.update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Function: Generate sale ticket number +CREATE OR REPLACE FUNCTION pos_micro.generate_ticket_number(p_tenant_id UUID) +RETURNS VARCHAR AS $$ +DECLARE + v_prefix VARCHAR; + v_seq INTEGER; + v_date VARCHAR; +BEGIN + v_date := TO_CHAR(CURRENT_DATE, 'YYMMDD'); + + SELECT COALESCE(MAX( + CAST(SUBSTRING(ticket_number FROM 8) AS INTEGER) + ), 0) + 1 + INTO v_seq + FROM pos_micro.sales + WHERE tenant_id = p_tenant_id + AND DATE(created_at) = CURRENT_DATE; + + RETURN 'T' || v_date || LPAD(v_seq::TEXT, 4, '0'); +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- TABLE 1: tenants (Multi-tenancy root) +-- ============================================================================ + +CREATE TABLE pos_micro.tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Business info + business_name VARCHAR(200) NOT NULL, + owner_name VARCHAR(200) NOT NULL, + phone VARCHAR(20) NOT NULL, + whatsapp VARCHAR(20), + email VARCHAR(255), + + -- Location (optional) + address TEXT, + city VARCHAR(100), + state VARCHAR(50) DEFAULT 'México', + + -- Subscription + plan VARCHAR(20) NOT NULL DEFAULT 'micro', -- micro, micro_plus + subscription_status VARCHAR(20) NOT NULL DEFAULT 'trial', -- trial, active, suspended, cancelled + trial_ends_at TIMESTAMP, + subscription_ends_at TIMESTAMP, + + -- Settings + currency VARCHAR(3) NOT NULL DEFAULT 'MXN', + tax_rate DECIMAL(5,2) NOT NULL DEFAULT 16.00, -- IVA México + timezone VARCHAR(50) DEFAULT 'America/Mexico_City', + settings JSONB DEFAULT '{}', + + -- Limits + max_products INTEGER NOT NULL DEFAULT 500, + max_sales_per_month INTEGER NOT NULL DEFAULT 1000, + current_month_sales INTEGER DEFAULT 0, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT chk_tenants_phone CHECK (phone ~ '^[0-9]{10}$'), + CONSTRAINT chk_tenants_tax_rate CHECK (tax_rate >= 0 AND tax_rate <= 100) +); + +CREATE INDEX idx_tenants_phone ON pos_micro.tenants(phone); +CREATE INDEX idx_tenants_subscription_status ON pos_micro.tenants(subscription_status); + +-- ============================================================================ +-- TABLE 2: users (Single user per tenant, simple auth) +-- ============================================================================ + +CREATE TABLE pos_micro.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES pos_micro.tenants(id) ON DELETE CASCADE, + + -- Auth + pin_hash VARCHAR(255) NOT NULL, -- 4-6 digit PIN for quick access + password_hash VARCHAR(255), -- Optional full password + + -- Profile + name VARCHAR(200) NOT NULL, + is_owner BOOLEAN NOT NULL DEFAULT TRUE, + + -- Session + last_login_at TIMESTAMP, + last_login_device JSONB, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT uq_users_tenant UNIQUE (tenant_id) -- Only 1 user per tenant in micro plan +); + +CREATE INDEX idx_users_tenant_id ON pos_micro.users(tenant_id); + +-- ============================================================================ +-- TABLE 3: categories (Product categories, max 20) +-- ============================================================================ + +CREATE TABLE pos_micro.categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES pos_micro.tenants(id) ON DELETE CASCADE, + + name VARCHAR(100) NOT NULL, + color VARCHAR(7) DEFAULT '#3B82F6', -- Hex color for UI + icon VARCHAR(50) DEFAULT 'package', -- Icon name + sort_order INTEGER DEFAULT 0, + + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT uq_categories_name_tenant UNIQUE (tenant_id, name) +); + +CREATE INDEX idx_categories_tenant_id ON pos_micro.categories(tenant_id); +CREATE INDEX idx_categories_active ON pos_micro.categories(tenant_id, is_active); + +-- ============================================================================ +-- TABLE 4: products (Max 500 per tenant) +-- ============================================================================ + +CREATE TABLE pos_micro.products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES pos_micro.tenants(id) ON DELETE CASCADE, + category_id UUID REFERENCES pos_micro.categories(id) ON DELETE SET NULL, + + -- Identification + sku VARCHAR(50), + barcode VARCHAR(50), + name VARCHAR(200) NOT NULL, + + -- Pricing + price DECIMAL(12,2) NOT NULL, + cost DECIMAL(12,2) DEFAULT 0, -- Optional: purchase cost + tax_included BOOLEAN NOT NULL DEFAULT TRUE, -- Price includes IVA + + -- Stock (simple) + track_stock BOOLEAN NOT NULL DEFAULT FALSE, + stock_quantity DECIMAL(12,3) DEFAULT 0, + low_stock_alert INTEGER DEFAULT 5, + + -- UI + image_url VARCHAR(500), + color VARCHAR(7), -- Quick color identification + is_favorite BOOLEAN DEFAULT FALSE, -- Show in quick access + sort_order INTEGER DEFAULT 0, + + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT uq_products_sku_tenant UNIQUE (tenant_id, sku), + CONSTRAINT uq_products_barcode_tenant UNIQUE (tenant_id, barcode), + CONSTRAINT chk_products_price CHECK (price >= 0), + CONSTRAINT chk_products_cost CHECK (cost >= 0) +); + +CREATE INDEX idx_products_tenant_id ON pos_micro.products(tenant_id); +CREATE INDEX idx_products_category_id ON pos_micro.products(category_id); +CREATE INDEX idx_products_barcode ON pos_micro.products(barcode); +CREATE INDEX idx_products_active ON pos_micro.products(tenant_id, is_active); +CREATE INDEX idx_products_favorite ON pos_micro.products(tenant_id, is_favorite) WHERE is_favorite = TRUE; + +-- ============================================================================ +-- TABLE 5: payment_methods (Cash, card, transfer) +-- ============================================================================ + +CREATE TABLE pos_micro.payment_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES pos_micro.tenants(id) ON DELETE CASCADE, + + code VARCHAR(20) NOT NULL, -- cash, card, transfer, other + name VARCHAR(100) NOT NULL, + icon VARCHAR(50) DEFAULT 'banknote', + + is_default BOOLEAN DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER DEFAULT 0, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT uq_payment_methods_code_tenant UNIQUE (tenant_id, code) +); + +CREATE INDEX idx_payment_methods_tenant_id ON pos_micro.payment_methods(tenant_id); + +-- ============================================================================ +-- TABLE 6: sales (Tickets/receipts) +-- ============================================================================ + +CREATE TABLE pos_micro.sales ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES pos_micro.tenants(id) ON DELETE CASCADE, + + -- Identification + ticket_number VARCHAR(20) NOT NULL, + + -- Totals + subtotal DECIMAL(12,2) NOT NULL, + tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0, + discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0, + total DECIMAL(12,2) NOT NULL, + + -- Payment + payment_method_id UUID REFERENCES pos_micro.payment_methods(id), + amount_received DECIMAL(12,2) NOT NULL, + change_amount DECIMAL(12,2) NOT NULL DEFAULT 0, + + -- Status + status VARCHAR(20) NOT NULL DEFAULT 'completed', -- completed, cancelled, refunded + cancelled_at TIMESTAMP, + cancel_reason VARCHAR(255), + + -- Customer (optional, for WhatsApp integration) + customer_name VARCHAR(200), + customer_phone VARCHAR(20), + + -- Metadata + notes TEXT, + device_info JSONB, -- Device that made the sale + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT uq_sales_ticket_tenant UNIQUE (tenant_id, ticket_number), + CONSTRAINT chk_sales_total CHECK (total >= 0), + CONSTRAINT chk_sales_amounts CHECK (amount_received >= total - discount_amount) +); + +CREATE INDEX idx_sales_tenant_id ON pos_micro.sales(tenant_id); +CREATE INDEX idx_sales_ticket_number ON pos_micro.sales(ticket_number); +CREATE INDEX idx_sales_created_at ON pos_micro.sales(created_at); +CREATE INDEX idx_sales_status ON pos_micro.sales(status); +CREATE INDEX idx_sales_customer_phone ON pos_micro.sales(customer_phone) WHERE customer_phone IS NOT NULL; + +-- ============================================================================ +-- TABLE 7: sale_items (Line items in a sale) +-- ============================================================================ + +CREATE TABLE pos_micro.sale_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + sale_id UUID NOT NULL REFERENCES pos_micro.sales(id) ON DELETE CASCADE, + product_id UUID REFERENCES pos_micro.products(id) ON DELETE SET NULL, + + -- Product snapshot (in case product changes/deleted) + product_name VARCHAR(200) NOT NULL, + product_sku VARCHAR(50), + + -- Amounts + quantity DECIMAL(12,3) NOT NULL, + unit_price DECIMAL(12,2) NOT NULL, + discount_percent DECIMAL(5,2) DEFAULT 0, + subtotal DECIMAL(12,2) NOT NULL, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT chk_sale_items_quantity CHECK (quantity > 0), + CONSTRAINT chk_sale_items_price CHECK (unit_price >= 0) +); + +CREATE INDEX idx_sale_items_sale_id ON pos_micro.sale_items(sale_id); +CREATE INDEX idx_sale_items_product_id ON pos_micro.sale_items(product_id); + +-- ============================================================================ +-- TABLE 8: cash_movements (Cash register tracking) +-- ============================================================================ + +CREATE TABLE pos_micro.cash_movements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES pos_micro.tenants(id) ON DELETE CASCADE, + + -- Type + type VARCHAR(20) NOT NULL, -- opening, sale, expense, withdrawal, closing + + -- Amount + amount DECIMAL(12,2) NOT NULL, + + -- Reference + sale_id UUID REFERENCES pos_micro.sales(id) ON DELETE SET NULL, + description VARCHAR(255), + + -- Balance (running) + balance_after DECIMAL(12,2) NOT NULL, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_cash_movements_tenant_id ON pos_micro.cash_movements(tenant_id); +CREATE INDEX idx_cash_movements_type ON pos_micro.cash_movements(type); +CREATE INDEX idx_cash_movements_created_at ON pos_micro.cash_movements(created_at); + +-- ============================================================================ +-- TABLE 9: daily_summaries (Daily closing reports) +-- ============================================================================ + +CREATE TABLE pos_micro.daily_summaries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES pos_micro.tenants(id) ON DELETE CASCADE, + + -- Period + summary_date DATE NOT NULL, + + -- Totals + total_sales INTEGER NOT NULL DEFAULT 0, + total_revenue DECIMAL(12,2) NOT NULL DEFAULT 0, + total_tax DECIMAL(12,2) NOT NULL DEFAULT 0, + total_discounts DECIMAL(12,2) NOT NULL DEFAULT 0, + + -- Payment breakdown + cash_total DECIMAL(12,2) DEFAULT 0, + card_total DECIMAL(12,2) DEFAULT 0, + other_total DECIMAL(12,2) DEFAULT 0, + + -- Cash register + opening_balance DECIMAL(12,2) DEFAULT 0, + closing_balance DECIMAL(12,2) DEFAULT 0, + total_expenses DECIMAL(12,2) DEFAULT 0, + expected_cash DECIMAL(12,2) DEFAULT 0, + actual_cash DECIMAL(12,2), + cash_difference DECIMAL(12,2), + + -- Status + is_closed BOOLEAN DEFAULT FALSE, + closed_at TIMESTAMP, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT uq_daily_summaries_tenant_date UNIQUE (tenant_id, summary_date) +); + +CREATE INDEX idx_daily_summaries_tenant_id ON pos_micro.daily_summaries(tenant_id); +CREATE INDEX idx_daily_summaries_date ON pos_micro.daily_summaries(summary_date); + +-- ============================================================================ +-- TABLE 10: whatsapp_sessions (WhatsApp bot integration) +-- ============================================================================ + +CREATE TABLE pos_micro.whatsapp_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES pos_micro.tenants(id) ON DELETE CASCADE, + + -- WhatsApp info + phone_number VARCHAR(20) NOT NULL, + wa_id VARCHAR(50), -- WhatsApp internal ID + + -- Session state + session_state JSONB DEFAULT '{}', -- Conversation state machine + last_interaction_at TIMESTAMP, + + -- Metrics + total_messages INTEGER DEFAULT 0, + total_orders INTEGER DEFAULT 0, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT uq_whatsapp_sessions_phone_tenant UNIQUE (tenant_id, phone_number) +); + +CREATE INDEX idx_whatsapp_sessions_tenant_id ON pos_micro.whatsapp_sessions(tenant_id); +CREATE INDEX idx_whatsapp_sessions_phone ON pos_micro.whatsapp_sessions(phone_number); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +-- Auto-update updated_at +CREATE TRIGGER trg_tenants_updated_at + BEFORE UPDATE ON pos_micro.tenants + FOR EACH ROW EXECUTE FUNCTION pos_micro.update_updated_at(); + +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON pos_micro.users + FOR EACH ROW EXECUTE FUNCTION pos_micro.update_updated_at(); + +CREATE TRIGGER trg_categories_updated_at + BEFORE UPDATE ON pos_micro.categories + FOR EACH ROW EXECUTE FUNCTION pos_micro.update_updated_at(); + +CREATE TRIGGER trg_products_updated_at + BEFORE UPDATE ON pos_micro.products + FOR EACH ROW EXECUTE FUNCTION pos_micro.update_updated_at(); + +CREATE TRIGGER trg_daily_summaries_updated_at + BEFORE UPDATE ON pos_micro.daily_summaries + FOR EACH ROW EXECUTE FUNCTION pos_micro.update_updated_at(); + +CREATE TRIGGER trg_whatsapp_sessions_updated_at + BEFORE UPDATE ON pos_micro.whatsapp_sessions + FOR EACH ROW EXECUTE FUNCTION pos_micro.update_updated_at(); + +-- ============================================================================ +-- TRIGGER: Auto-generate ticket number +-- ============================================================================ + +CREATE OR REPLACE FUNCTION pos_micro.auto_ticket_number() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.ticket_number IS NULL THEN + NEW.ticket_number := pos_micro.generate_ticket_number(NEW.tenant_id); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_sales_ticket_number + BEFORE INSERT ON pos_micro.sales + FOR EACH ROW EXECUTE FUNCTION pos_micro.auto_ticket_number(); + +-- ============================================================================ +-- TRIGGER: Update stock on sale +-- ============================================================================ + +CREATE OR REPLACE FUNCTION pos_micro.update_stock_on_sale() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE pos_micro.products + SET stock_quantity = stock_quantity - NEW.quantity + WHERE id = NEW.product_id + AND track_stock = TRUE; + ELSIF TG_OP = 'DELETE' THEN + UPDATE pos_micro.products + SET stock_quantity = stock_quantity + OLD.quantity + WHERE id = OLD.product_id + AND track_stock = TRUE; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_sale_items_stock + AFTER INSERT OR DELETE ON pos_micro.sale_items + FOR EACH ROW EXECUTE FUNCTION pos_micro.update_stock_on_sale(); + +-- ============================================================================ +-- TRIGGER: Increment monthly sales counter +-- ============================================================================ + +CREATE OR REPLACE FUNCTION pos_micro.increment_monthly_sales() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status = 'completed' THEN + UPDATE pos_micro.tenants + SET current_month_sales = current_month_sales + 1 + WHERE id = NEW.tenant_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_sales_monthly_counter + AFTER INSERT ON pos_micro.sales + FOR EACH ROW EXECUTE FUNCTION pos_micro.increment_monthly_sales(); + +-- ============================================================================ +-- VIEWS +-- ============================================================================ + +-- View: Products with stock alerts +CREATE OR REPLACE VIEW pos_micro.products_low_stock AS +SELECT + p.id, + p.tenant_id, + p.name, + p.sku, + p.stock_quantity, + p.low_stock_alert, + c.name as category_name +FROM pos_micro.products p +LEFT JOIN pos_micro.categories c ON p.category_id = c.id +WHERE p.track_stock = TRUE + AND p.stock_quantity <= p.low_stock_alert + AND p.is_active = TRUE; + +-- View: Today's sales summary +CREATE OR REPLACE VIEW pos_micro.today_sales AS +SELECT + tenant_id, + COUNT(*) as total_sales, + SUM(total) as total_revenue, + SUM(tax_amount) as total_tax, + AVG(total) as avg_ticket +FROM pos_micro.sales +WHERE DATE(created_at) = CURRENT_DATE + AND status = 'completed' +GROUP BY tenant_id; + +-- View: Top products (last 30 days) +CREATE OR REPLACE VIEW pos_micro.top_products AS +SELECT + p.tenant_id, + p.id as product_id, + p.name as product_name, + SUM(si.quantity) as total_quantity, + SUM(si.subtotal) as total_revenue, + COUNT(DISTINCT s.id) as times_sold +FROM pos_micro.sale_items si +JOIN pos_micro.sales s ON si.sale_id = s.id +JOIN pos_micro.products p ON si.product_id = p.id +WHERE s.created_at >= CURRENT_DATE - INTERVAL '30 days' + AND s.status = 'completed' +GROUP BY p.tenant_id, p.id, p.name +ORDER BY total_revenue DESC; + +-- ============================================================================ +-- SEED DATA: Default payment methods +-- ============================================================================ + +-- This will be inserted per-tenant on registration +-- Just documenting the expected defaults here + +/* +INSERT INTO pos_micro.payment_methods (tenant_id, code, name, icon, is_default, sort_order) VALUES +('{tenant_id}', 'cash', 'Efectivo', 'banknote', TRUE, 1), +('{tenant_id}', 'card', 'Tarjeta', 'credit-card', FALSE, 2), +('{tenant_id}', 'transfer', 'Transferencia', 'smartphone', FALSE, 3); +*/ + +-- ============================================================================ +-- COMMENTS +-- ============================================================================ + +COMMENT ON SCHEMA pos_micro IS 'POS Micro - Ultra-minimal point of sale for street vendors'; +COMMENT ON TABLE pos_micro.tenants IS 'Tenant/business registration with subscription limits'; +COMMENT ON TABLE pos_micro.users IS 'Single user per tenant (micro plan limit)'; +COMMENT ON TABLE pos_micro.categories IS 'Product categories (max 20 per tenant)'; +COMMENT ON TABLE pos_micro.products IS 'Products catalog (max 500 per tenant)'; +COMMENT ON TABLE pos_micro.payment_methods IS 'Accepted payment methods'; +COMMENT ON TABLE pos_micro.sales IS 'Sales/tickets with totals'; +COMMENT ON TABLE pos_micro.sale_items IS 'Line items per sale'; +COMMENT ON TABLE pos_micro.cash_movements IS 'Cash register movements'; +COMMENT ON TABLE pos_micro.daily_summaries IS 'Daily closing reports'; +COMMENT ON TABLE pos_micro.whatsapp_sessions IS 'WhatsApp bot conversation sessions'; + +-- ============================================================================ +-- SCHEMA COMPLETE +-- ============================================================================ + +DO $$ +BEGIN + RAISE NOTICE 'POS Micro schema created successfully!'; + RAISE NOTICE 'Tables: 10 (tenants, users, categories, products, payment_methods, sales, sale_items, cash_movements, daily_summaries, whatsapp_sessions)'; + RAISE NOTICE 'Target: Street vendors, small shops, food stands'; + RAISE NOTICE 'Price: 100 MXN/month'; +END $$; diff --git a/projects/erp-suite/apps/products/pos-micro/docker-compose.yml b/projects/erp-suite/apps/products/pos-micro/docker-compose.yml new file mode 100644 index 0000000..2ff6115 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/docker-compose.yml @@ -0,0 +1,73 @@ +version: '3.8' + +services: + # PostgreSQL Database + postgres: + image: postgres:15-alpine + container_name: pos-micro-db + environment: + POSTGRES_USER: pos_micro + POSTGRES_PASSWORD: pos_micro_secret + POSTGRES_DB: pos_micro_db + ports: + - "5433:5432" + volumes: + - pos_micro_data:/var/lib/postgresql/data + - ./database/ddl:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pos_micro -d pos_micro_db"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - pos-micro-network + + # Backend API (NestJS) + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: pos-micro-api + environment: + NODE_ENV: development + PORT: 3000 + DB_HOST: postgres + DB_PORT: 5432 + DB_USERNAME: pos_micro + DB_PASSWORD: pos_micro_secret + DB_DATABASE: pos_micro_db + DB_SCHEMA: pos_micro + JWT_SECRET: pos-micro-jwt-secret-change-in-production + JWT_EXPIRES_IN: 24h + JWT_REFRESH_EXPIRES_IN: 7d + ports: + - "3000:3000" + depends_on: + postgres: + condition: service_healthy + volumes: + - ./backend/src:/app/src:ro + networks: + - pos-micro-network + + # Frontend PWA (Vite + React) + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: pos-micro-web + environment: + VITE_API_URL: http://localhost:3000/api/v1 + ports: + - "5173:5173" + volumes: + - ./frontend/src:/app/src:ro + networks: + - pos-micro-network + +volumes: + pos_micro_data: + +networks: + pos-micro-network: + driver: bridge diff --git a/projects/erp-suite/apps/products/pos-micro/scripts/dev.sh b/projects/erp-suite/apps/products/pos-micro/scripts/dev.sh new file mode 100755 index 0000000..f1ebafb --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/scripts/dev.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# ============================================================================= +# POS MICRO - Development Script +# ============================================================================= + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}" +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ POS MICRO ║" +echo "║ Development Environment ║" +echo "╚══════════════════════════════════════════════════════════════╝" +echo -e "${NC}" + +case "${1:-up}" in + up) + echo -e "${YELLOW}Starting development environment...${NC}" + docker-compose up -d postgres + echo -e "${GREEN}Waiting for database...${NC}" + sleep 5 + echo -e "${GREEN}Starting backend...${NC}" + cd backend && npm run start:dev & + echo -e "${GREEN}Starting frontend...${NC}" + cd ../frontend && npm run dev & + wait + ;; + + down) + echo -e "${YELLOW}Stopping development environment...${NC}" + docker-compose down + pkill -f "nest start" || true + pkill -f "vite" || true + ;; + + db) + echo -e "${YELLOW}Starting database only...${NC}" + docker-compose up -d postgres + echo -e "${GREEN}Database available at localhost:5433${NC}" + ;; + + logs) + docker-compose logs -f + ;; + + reset-db) + echo -e "${RED}Resetting database...${NC}" + docker-compose down -v + docker-compose up -d postgres + sleep 5 + echo -e "${GREEN}Database reset complete!${NC}" + ;; + + *) + echo "Usage: $0 {up|down|db|logs|reset-db}" + echo "" + echo "Commands:" + echo " up - Start full development environment" + echo " down - Stop all services" + echo " db - Start database only" + echo " logs - View container logs" + echo " reset-db - Reset database (WARNING: deletes all data)" + exit 1 + ;; +esac diff --git a/projects/erp-suite/apps/saas/billing/database/ddl/00-schema.sql b/projects/erp-suite/apps/saas/billing/database/ddl/00-schema.sql new file mode 100644 index 0000000..3d0b5a2 --- /dev/null +++ b/projects/erp-suite/apps/saas/billing/database/ddl/00-schema.sql @@ -0,0 +1,676 @@ +-- ============================================================================ +-- SAAS LAYER - BILLING SCHEMA +-- ============================================================================ +-- Version: 1.0.0 +-- Description: Billing, subscriptions, and payments management for SaaS +-- Target: All ERP Suite products (POS Micro, ERP Básico, Verticales) +-- ============================================================================ + +-- Enable required extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ============================================================================ +-- SCHEMA CREATION +-- ============================================================================ + +CREATE SCHEMA IF NOT EXISTS saas; + +SET search_path TO saas, public; + +-- ============================================================================ +-- ENUMS +-- ============================================================================ + +CREATE TYPE saas.plan_type AS ENUM ( + 'pos_micro', -- 100 MXN/mes + 'erp_basic', -- 300 MXN/mes + 'erp_pro', -- 500 MXN/mes + 'vertical' -- 1000+ MXN/mes +); + +CREATE TYPE saas.billing_cycle AS ENUM ( + 'monthly', + 'yearly' +); + +CREATE TYPE saas.subscription_status AS ENUM ( + 'trial', + 'active', + 'past_due', + 'suspended', + 'cancelled', + 'expired' +); + +CREATE TYPE saas.payment_status AS ENUM ( + 'pending', + 'processing', + 'completed', + 'failed', + 'refunded', + 'cancelled' +); + +CREATE TYPE saas.invoice_status AS ENUM ( + 'draft', + 'pending', + 'paid', + 'overdue', + 'cancelled', + 'refunded' +); + +CREATE TYPE saas.payment_provider AS ENUM ( + 'stripe', + 'conekta', + 'oxxo', + 'transfer', + 'manual' +); + +-- ============================================================================ +-- TABLE 1: plans (Subscription plans) +-- ============================================================================ + +CREATE TABLE saas.plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identification + code VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Type + plan_type saas.plan_type NOT NULL, + + -- Pricing (MXN) + price_monthly DECIMAL(10,2) NOT NULL, + price_yearly DECIMAL(10,2), -- Usually with discount + + -- Limits + max_users INTEGER NOT NULL DEFAULT 1, + max_products INTEGER DEFAULT 500, + max_sales_per_month INTEGER DEFAULT 1000, + max_storage_mb INTEGER DEFAULT 100, + + -- Features (JSON for flexibility) + features JSONB NOT NULL DEFAULT '{}', + + -- Status + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_public BOOLEAN NOT NULL DEFAULT TRUE, -- Show in pricing page + sort_order INTEGER DEFAULT 0, + + -- Stripe/Conekta IDs + stripe_price_id_monthly VARCHAR(100), + stripe_price_id_yearly VARCHAR(100), + conekta_plan_id VARCHAR(100), + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP +); + +-- ============================================================================ +-- TABLE 2: plan_features (Feature definitions) +-- ============================================================================ + +CREATE TABLE saas.plan_features ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + plan_id UUID NOT NULL REFERENCES saas.plans(id) ON DELETE CASCADE, + + -- Feature + feature_code VARCHAR(50) NOT NULL, -- e.g., 'accounting', 'hr', 'cfdi' + feature_name VARCHAR(100) NOT NULL, + + -- Pricing (for add-ons) + is_included BOOLEAN NOT NULL DEFAULT FALSE, + addon_price_monthly DECIMAL(10,2), -- Price if add-on + + -- Status + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + CONSTRAINT uq_plan_features UNIQUE (plan_id, feature_code) +); + +-- ============================================================================ +-- TABLE 3: tenants (All SaaS customers) +-- ============================================================================ + +CREATE TABLE saas.tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Business info + business_name VARCHAR(200) NOT NULL, + legal_name VARCHAR(200), + tax_id VARCHAR(20), -- RFC México + + -- Contact + owner_name VARCHAR(200) NOT NULL, + email VARCHAR(255) NOT NULL, + phone VARCHAR(20) NOT NULL, + whatsapp VARCHAR(20), + + -- Address + address TEXT, + city VARCHAR(100), + state VARCHAR(50), + zip_code VARCHAR(10), + country VARCHAR(2) DEFAULT 'MX', + + -- Product reference (which product schema to use) + product_type saas.plan_type NOT NULL, + product_schema VARCHAR(50), -- e.g., 'pos_micro', 'erp_basic' + + -- Settings + timezone VARCHAR(50) DEFAULT 'America/Mexico_City', + currency VARCHAR(3) DEFAULT 'MXN', + language VARCHAR(10) DEFAULT 'es', + settings JSONB DEFAULT '{}', + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT uq_tenants_email UNIQUE (email), + CONSTRAINT uq_tenants_phone UNIQUE (phone) +); + +CREATE INDEX idx_tenants_email ON saas.tenants(email); +CREATE INDEX idx_tenants_phone ON saas.tenants(phone); +CREATE INDEX idx_tenants_product_type ON saas.tenants(product_type); + +-- ============================================================================ +-- TABLE 4: subscriptions (Active subscriptions) +-- ============================================================================ + +CREATE TABLE saas.subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, + plan_id UUID NOT NULL REFERENCES saas.plans(id), + + -- Billing + billing_cycle saas.billing_cycle NOT NULL DEFAULT 'monthly', + current_price DECIMAL(10,2) NOT NULL, + + -- Dates + trial_starts_at TIMESTAMP, + trial_ends_at TIMESTAMP, + current_period_start TIMESTAMP NOT NULL, + current_period_end TIMESTAMP NOT NULL, + cancelled_at TIMESTAMP, + + -- Status + status saas.subscription_status NOT NULL DEFAULT 'trial', + cancel_reason TEXT, + + -- External IDs + stripe_subscription_id VARCHAR(100), + conekta_subscription_id VARCHAR(100), + + -- Features (active add-ons) + active_features JSONB DEFAULT '[]', + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT uq_subscriptions_tenant UNIQUE (tenant_id) -- One active subscription per tenant +); + +CREATE INDEX idx_subscriptions_tenant_id ON saas.subscriptions(tenant_id); +CREATE INDEX idx_subscriptions_status ON saas.subscriptions(status); +CREATE INDEX idx_subscriptions_period_end ON saas.subscriptions(current_period_end); + +-- ============================================================================ +-- TABLE 5: invoices (Billing invoices) +-- ============================================================================ + +CREATE TABLE saas.invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, + subscription_id UUID REFERENCES saas.subscriptions(id), + + -- Invoice number + invoice_number VARCHAR(50) NOT NULL, + + -- Dates + invoice_date DATE NOT NULL DEFAULT CURRENT_DATE, + due_date DATE NOT NULL, + paid_at TIMESTAMP, + + -- Amounts + subtotal DECIMAL(10,2) NOT NULL, + tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + total DECIMAL(10,2) NOT NULL, + + -- Currency + currency VARCHAR(3) NOT NULL DEFAULT 'MXN', + + -- Status + status saas.invoice_status NOT NULL DEFAULT 'draft', + + -- CFDI (Mexico) + cfdi_uuid VARCHAR(50), + cfdi_xml TEXT, + cfdi_pdf_url VARCHAR(500), + + -- External IDs + stripe_invoice_id VARCHAR(100), + conekta_order_id VARCHAR(100), + + -- Notes + notes TEXT, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT uq_invoices_number UNIQUE (invoice_number) +); + +CREATE INDEX idx_invoices_tenant_id ON saas.invoices(tenant_id); +CREATE INDEX idx_invoices_status ON saas.invoices(status); +CREATE INDEX idx_invoices_due_date ON saas.invoices(due_date); + +-- ============================================================================ +-- TABLE 6: invoice_items (Invoice line items) +-- ============================================================================ + +CREATE TABLE saas.invoice_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id UUID NOT NULL REFERENCES saas.invoices(id) ON DELETE CASCADE, + + -- Description + description VARCHAR(255) NOT NULL, + + -- Amounts + quantity INTEGER NOT NULL DEFAULT 1, + unit_price DECIMAL(10,2) NOT NULL, + subtotal DECIMAL(10,2) NOT NULL, + + -- Reference + plan_id UUID REFERENCES saas.plans(id), + feature_code VARCHAR(50), + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_invoice_items_invoice_id ON saas.invoice_items(invoice_id); + +-- ============================================================================ +-- TABLE 7: payments (Payment transactions) +-- ============================================================================ + +CREATE TABLE saas.payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, + invoice_id UUID REFERENCES saas.invoices(id), + + -- Amount + amount DECIMAL(10,2) NOT NULL, + currency VARCHAR(3) NOT NULL DEFAULT 'MXN', + + -- Provider + provider saas.payment_provider NOT NULL, + provider_payment_id VARCHAR(100), + provider_customer_id VARCHAR(100), + + -- Status + status saas.payment_status NOT NULL DEFAULT 'pending', + + -- Details + payment_method_type VARCHAR(50), -- card, oxxo, spei, etc. + last_four VARCHAR(4), + brand VARCHAR(20), -- visa, mastercard, etc. + + -- Error handling + failure_code VARCHAR(50), + failure_message TEXT, + + -- Dates + paid_at TIMESTAMP, + refunded_at TIMESTAMP, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE INDEX idx_payments_tenant_id ON saas.payments(tenant_id); +CREATE INDEX idx_payments_invoice_id ON saas.payments(invoice_id); +CREATE INDEX idx_payments_status ON saas.payments(status); +CREATE INDEX idx_payments_provider ON saas.payments(provider); + +-- ============================================================================ +-- TABLE 8: payment_methods (Saved payment methods) +-- ============================================================================ + +CREATE TABLE saas.payment_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, + + -- Provider + provider saas.payment_provider NOT NULL, + provider_payment_method_id VARCHAR(100) NOT NULL, + + -- Card details (if applicable) + card_type VARCHAR(20), + card_brand VARCHAR(20), + card_last_four VARCHAR(4), + card_exp_month INTEGER, + card_exp_year INTEGER, + + -- Status + is_default BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE INDEX idx_payment_methods_tenant_id ON saas.payment_methods(tenant_id); + +-- ============================================================================ +-- TABLE 9: usage_tracking (Usage metrics for billing) +-- ============================================================================ + +CREATE TABLE saas.usage_tracking ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, + + -- Period + period_start DATE NOT NULL, + period_end DATE NOT NULL, + + -- Metrics + users_count INTEGER DEFAULT 0, + products_count INTEGER DEFAULT 0, + sales_count INTEGER DEFAULT 0, + storage_used_mb INTEGER DEFAULT 0, + api_calls INTEGER DEFAULT 0, + + -- WhatsApp/AI usage (billable) + whatsapp_messages INTEGER DEFAULT 0, + ai_tokens_used INTEGER DEFAULT 0, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT uq_usage_tracking_tenant_period UNIQUE (tenant_id, period_start, period_end) +); + +CREATE INDEX idx_usage_tracking_tenant_id ON saas.usage_tracking(tenant_id); +CREATE INDEX idx_usage_tracking_period ON saas.usage_tracking(period_start, period_end); + +-- ============================================================================ +-- TABLE 10: support_tickets (Basic support) +-- ============================================================================ + +CREATE TABLE saas.support_tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, + + -- Ticket + ticket_number VARCHAR(20) NOT NULL, + subject VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + + -- Category + category VARCHAR(50) NOT NULL DEFAULT 'general', -- billing, technical, feature, general + priority VARCHAR(20) NOT NULL DEFAULT 'normal', -- low, normal, high, urgent + + -- Status + status VARCHAR(20) NOT NULL DEFAULT 'open', -- open, in_progress, waiting, resolved, closed + + -- Assignment + assigned_to VARCHAR(100), + + -- Resolution + resolution TEXT, + resolved_at TIMESTAMP, + + -- Audit + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + + CONSTRAINT uq_support_tickets_number UNIQUE (ticket_number) +); + +CREATE INDEX idx_support_tickets_tenant_id ON saas.support_tickets(tenant_id); +CREATE INDEX idx_support_tickets_status ON saas.support_tickets(status); + +-- ============================================================================ +-- UTILITY FUNCTIONS +-- ============================================================================ + +-- Function: Update updated_at timestamp +CREATE OR REPLACE FUNCTION saas.update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Function: Generate invoice number +CREATE OR REPLACE FUNCTION saas.generate_invoice_number() +RETURNS VARCHAR AS $$ +DECLARE + v_year VARCHAR; + v_seq INTEGER; +BEGIN + v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); + + SELECT COALESCE(MAX( + CAST(SUBSTRING(invoice_number FROM 5) AS INTEGER) + ), 0) + 1 + INTO v_seq + FROM saas.invoices + WHERE invoice_number LIKE v_year || '%'; + + RETURN v_year || LPAD(v_seq::TEXT, 6, '0'); +END; +$$ LANGUAGE plpgsql; + +-- Function: Generate ticket number +CREATE OR REPLACE FUNCTION saas.generate_ticket_number() +RETURNS VARCHAR AS $$ +DECLARE + v_date VARCHAR; + v_seq INTEGER; +BEGIN + v_date := TO_CHAR(CURRENT_DATE, 'YYMMDD'); + + SELECT COALESCE(MAX( + CAST(SUBSTRING(ticket_number FROM 8) AS INTEGER) + ), 0) + 1 + INTO v_seq + FROM saas.support_tickets + WHERE DATE(created_at) = CURRENT_DATE; + + RETURN 'TK' || v_date || LPAD(v_seq::TEXT, 4, '0'); +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER trg_plans_updated_at + BEFORE UPDATE ON saas.plans + FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); + +CREATE TRIGGER trg_tenants_updated_at + BEFORE UPDATE ON saas.tenants + FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); + +CREATE TRIGGER trg_subscriptions_updated_at + BEFORE UPDATE ON saas.subscriptions + FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); + +CREATE TRIGGER trg_invoices_updated_at + BEFORE UPDATE ON saas.invoices + FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); + +CREATE TRIGGER trg_payments_updated_at + BEFORE UPDATE ON saas.payments + FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); + +CREATE TRIGGER trg_payment_methods_updated_at + BEFORE UPDATE ON saas.payment_methods + FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); + +CREATE TRIGGER trg_usage_tracking_updated_at + BEFORE UPDATE ON saas.usage_tracking + FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); + +CREATE TRIGGER trg_support_tickets_updated_at + BEFORE UPDATE ON saas.support_tickets + FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); + +-- Auto-generate invoice number +CREATE OR REPLACE FUNCTION saas.auto_invoice_number() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.invoice_number IS NULL THEN + NEW.invoice_number := saas.generate_invoice_number(); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_invoices_auto_number + BEFORE INSERT ON saas.invoices + FOR EACH ROW EXECUTE FUNCTION saas.auto_invoice_number(); + +-- Auto-generate ticket number +CREATE OR REPLACE FUNCTION saas.auto_ticket_number() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.ticket_number IS NULL THEN + NEW.ticket_number := saas.generate_ticket_number(); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_tickets_auto_number + BEFORE INSERT ON saas.support_tickets + FOR EACH ROW EXECUTE FUNCTION saas.auto_ticket_number(); + +-- ============================================================================ +-- SEED DATA: Default plans +-- ============================================================================ + +INSERT INTO saas.plans (code, name, description, plan_type, price_monthly, price_yearly, max_users, max_products, max_sales_per_month, features) VALUES +-- POS Micro +('pos_micro', 'POS Micro', 'Punto de venta ultra-básico para pequeños negocios', 'pos_micro', 100.00, 1000.00, 1, 500, 1000, + '{"offline_mode": true, "whatsapp_basic": true, "reports_basic": true}'), + +-- ERP Básico +('erp_basic', 'ERP Básico', 'ERP completo pero austero para PyMEs', 'erp_basic', 300.00, 3000.00, 5, 10000, 5000, + '{"inventory": true, "sales": true, "purchases": true, "reports": true, "multi_warehouse": false}'), + +-- ERP Pro +('erp_pro', 'ERP Pro', 'ERP completo con módulos avanzados', 'erp_pro', 500.00, 5000.00, 10, 50000, 20000, + '{"inventory": true, "sales": true, "purchases": true, "reports": true, "multi_warehouse": true, "accounting_basic": true}'), + +-- Vertical (template) +('vertical_base', 'Vertical Industry', 'ERP especializado por industria', 'vertical', 1000.00, 10000.00, 20, 100000, 50000, + '{"inventory": true, "sales": true, "purchases": true, "reports": true, "multi_warehouse": true, "accounting": true, "industry_specific": true}'); + +-- Feature definitions +INSERT INTO saas.plan_features (plan_id, feature_code, feature_name, is_included, addon_price_monthly) VALUES +-- POS Micro add-ons +((SELECT id FROM saas.plans WHERE code = 'pos_micro'), 'cfdi', 'Facturación CFDI', FALSE, 50.00), +((SELECT id FROM saas.plans WHERE code = 'pos_micro'), 'whatsapp_pro', 'WhatsApp Avanzado', FALSE, 100.00), + +-- ERP Básico add-ons +((SELECT id FROM saas.plans WHERE code = 'erp_basic'), 'accounting', 'Contabilidad', FALSE, 150.00), +((SELECT id FROM saas.plans WHERE code = 'erp_basic'), 'hr', 'Recursos Humanos', FALSE, 100.00), +((SELECT id FROM saas.plans WHERE code = 'erp_basic'), 'cfdi', 'Facturación CFDI', FALSE, 100.00), +((SELECT id FROM saas.plans WHERE code = 'erp_basic'), 'extra_user', 'Usuario Extra', FALSE, 50.00), +((SELECT id FROM saas.plans WHERE code = 'erp_basic'), 'multi_warehouse', 'Multi-Almacén', FALSE, 100.00), + +-- ERP Pro included features +((SELECT id FROM saas.plans WHERE code = 'erp_pro'), 'accounting', 'Contabilidad Básica', TRUE, NULL), +((SELECT id FROM saas.plans WHERE code = 'erp_pro'), 'multi_warehouse', 'Multi-Almacén', TRUE, NULL), +((SELECT id FROM saas.plans WHERE code = 'erp_pro'), 'cfdi', 'Facturación CFDI', FALSE, 100.00), +((SELECT id FROM saas.plans WHERE code = 'erp_pro'), 'hr', 'Recursos Humanos', FALSE, 100.00), +((SELECT id FROM saas.plans WHERE code = 'erp_pro'), 'extra_user', 'Usuario Extra', FALSE, 50.00); + +-- ============================================================================ +-- VIEWS +-- ============================================================================ + +-- View: Active subscriptions with plan details +CREATE OR REPLACE VIEW saas.active_subscriptions_view AS +SELECT + s.id, + s.tenant_id, + t.business_name, + t.email, + p.code as plan_code, + p.name as plan_name, + s.billing_cycle, + s.current_price, + s.status, + s.current_period_start, + s.current_period_end, + s.trial_ends_at, + CASE + WHEN s.status = 'trial' THEN s.trial_ends_at - CURRENT_TIMESTAMP + ELSE s.current_period_end - CURRENT_TIMESTAMP + END as time_remaining +FROM saas.subscriptions s +JOIN saas.tenants t ON s.tenant_id = t.id +JOIN saas.plans p ON s.plan_id = p.id +WHERE s.status IN ('trial', 'active', 'past_due'); + +-- View: Revenue by plan +CREATE OR REPLACE VIEW saas.revenue_by_plan AS +SELECT + p.code as plan_code, + p.name as plan_name, + COUNT(s.id) as active_subscriptions, + SUM(s.current_price) as monthly_revenue +FROM saas.plans p +LEFT JOIN saas.subscriptions s ON p.id = s.plan_id + AND s.status IN ('active', 'trial') +GROUP BY p.id, p.code, p.name +ORDER BY monthly_revenue DESC NULLS LAST; + +-- ============================================================================ +-- COMMENTS +-- ============================================================================ + +COMMENT ON SCHEMA saas IS 'SaaS Layer - Billing, subscriptions, and multi-tenancy management'; +COMMENT ON TABLE saas.plans IS 'Available subscription plans with pricing'; +COMMENT ON TABLE saas.plan_features IS 'Features included or available as add-ons per plan'; +COMMENT ON TABLE saas.tenants IS 'All SaaS customers/businesses'; +COMMENT ON TABLE saas.subscriptions IS 'Active subscriptions per tenant'; +COMMENT ON TABLE saas.invoices IS 'Generated invoices for billing'; +COMMENT ON TABLE saas.payments IS 'Payment transactions'; +COMMENT ON TABLE saas.payment_methods IS 'Saved payment methods per tenant'; +COMMENT ON TABLE saas.usage_tracking IS 'Usage metrics for billing and limits'; +COMMENT ON TABLE saas.support_tickets IS 'Customer support tickets'; + +-- ============================================================================ +-- SCHEMA COMPLETE +-- ============================================================================ + +DO $$ +BEGIN + RAISE NOTICE 'SaaS Billing schema created successfully!'; + RAISE NOTICE 'Tables: 10 (plans, plan_features, tenants, subscriptions, invoices, invoice_items, payments, payment_methods, usage_tracking, support_tickets)'; + RAISE NOTICE 'Default plans: POS Micro (100 MXN), ERP Básico (300 MXN), ERP Pro (500 MXN), Vertical (1000+ MXN)'; +END $$;