feat(pos-micro): Implement MVP backend and database schema
POS Micro - Ultra-minimal point of sale (100 MXN/month): - Database DDL: 10 tables (tenants, users, categories, products, payment_methods, sales, sale_items, cash_movements, daily_summaries, whatsapp_sessions) - NestJS Backend: Complete API with modules for auth, products, categories, sales, and payments - Docker Compose: Development environment with PostgreSQL - Dev scripts: Easy setup for local development SaaS Billing Schema: - 10 tables for subscription management (plans, subscriptions, invoices, payments, usage_tracking, support_tickets) - Default plans: POS Micro (100 MXN), ERP Básico (300 MXN), ERP Pro (500 MXN), Vertical (1000+ MXN) - Support for Stripe and Conekta payment providers - CFDI integration ready for Mexico Target: Mexican informal market (street vendors, small shops, food stands) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2781837d9e
commit
9bfc6fb152
@ -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
|
||||
@ -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"]
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
@ -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' };
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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<Tenant>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto): Promise<AuthResponse> {
|
||||
// Check if phone already registered
|
||||
const existingTenant = await this.tenantRepository.findOne({
|
||||
where: { phone: dto.phone },
|
||||
});
|
||||
|
||||
if (existingTenant) {
|
||||
throw new ConflictException('Este teléfono ya está registrado');
|
||||
}
|
||||
|
||||
// Hash PIN
|
||||
const pinHash = await bcrypt.hash(dto.pin, 10);
|
||||
|
||||
// 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<AuthResponse> {
|
||||
// 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<AuthResponse> {
|
||||
try {
|
||||
const payload = this.jwtService.verify<TokenPayload>(refreshToken, {
|
||||
secret: this.configService.get('JWT_SECRET'),
|
||||
});
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Token inválido');
|
||||
}
|
||||
|
||||
const tenant = await this.tenantRepository.findOne({
|
||||
where: { id: user.tenantId },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new UnauthorizedException('Token inválido');
|
||||
}
|
||||
|
||||
return this.generateTokens(user, tenant);
|
||||
} catch {
|
||||
throw new UnauthorizedException('Token inválido o expirado');
|
||||
}
|
||||
}
|
||||
|
||||
async changePin(userId: string, currentPin: string, newPin: string): Promise<void> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Usuario no encontrado');
|
||||
}
|
||||
|
||||
const isValidPin = await bcrypt.compare(currentPin, user.pinHash);
|
||||
|
||||
if (!isValidPin) {
|
||||
throw new UnauthorizedException('PIN actual incorrecto');
|
||||
}
|
||||
|
||||
user.pinHash = await bcrypt.hash(newPin, 10);
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
private generateTokens(user: User, tenant: Tenant): AuthResponse {
|
||||
const payload: TokenPayload = {
|
||||
sub: user.id,
|
||||
tenantId: tenant.id,
|
||||
phone: 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<string, unknown>;
|
||||
|
||||
@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[];
|
||||
}
|
||||
@ -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<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant, (tenant) => tenant.users)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
canActivate(context: ExecutionContext) {
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest<TUser>(err: Error | null, user: TUser): TUser {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException('Token inválido o expirado');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../entities/user.entity';
|
||||
import { TokenPayload } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: TokenPayload) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Usuario no encontrado');
|
||||
}
|
||||
|
||||
return {
|
||||
sub: payload.sub,
|
||||
tenantId: payload.tenantId,
|
||||
phone: payload.phone,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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<Category>,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, includeInactive = false): Promise<Category[]> {
|
||||
const where: Record<string, unknown> = { tenantId };
|
||||
|
||||
if (!includeInactive) {
|
||||
where.isActive = true;
|
||||
}
|
||||
|
||||
return this.categoryRepository.find({
|
||||
where,
|
||||
order: { sortOrder: 'ASC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, id: string): Promise<Category> {
|
||||
const category = await this.categoryRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundException('Categoría no encontrada');
|
||||
}
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateCategoryDto): Promise<Category> {
|
||||
// Check limit
|
||||
const count = await this.categoryRepository.count({ where: { tenantId } });
|
||||
|
||||
if (count >= MAX_CATEGORIES) {
|
||||
throw new BadRequestException(
|
||||
`Has alcanzado el límite de ${MAX_CATEGORIES} categorías`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
const existing = await this.categoryRepository.findOne({
|
||||
where: { tenantId, name: dto.name },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Ya existe una categoría con ese nombre');
|
||||
}
|
||||
|
||||
const category = this.categoryRepository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
});
|
||||
|
||||
return this.categoryRepository.save(category);
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateCategoryDto): Promise<Category> {
|
||||
const category = await this.findOne(tenantId, id);
|
||||
|
||||
// Check name uniqueness if changed
|
||||
if (dto.name && dto.name !== category.name) {
|
||||
const existing = await this.categoryRepository.findOne({
|
||||
where: { tenantId, name: dto.name },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Ya existe una categoría con ese nombre');
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(category, dto);
|
||||
return this.categoryRepository.save(category);
|
||||
}
|
||||
|
||||
async delete(tenantId: string, id: string): Promise<void> {
|
||||
const category = await this.findOne(tenantId, id);
|
||||
await this.categoryRepository.remove(category);
|
||||
}
|
||||
|
||||
async toggleActive(tenantId: string, id: string): Promise<Category> {
|
||||
const category = await this.findOne(tenantId, id);
|
||||
category.isActive = !category.isActive;
|
||||
return this.categoryRepository.save(category);
|
||||
}
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -0,0 +1,84 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PaymentMethod } from './entities/payment-method.entity';
|
||||
|
||||
const DEFAULT_PAYMENT_METHODS = [
|
||||
{ code: 'cash', name: 'Efectivo', icon: 'banknote', isDefault: true, sortOrder: 1 },
|
||||
{ code: 'card', name: 'Tarjeta', icon: 'credit-card', isDefault: false, sortOrder: 2 },
|
||||
{ code: 'transfer', name: 'Transferencia', icon: 'smartphone', isDefault: false, sortOrder: 3 },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
constructor(
|
||||
@InjectRepository(PaymentMethod)
|
||||
private readonly paymentMethodRepository: Repository<PaymentMethod>,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string): Promise<PaymentMethod[]> {
|
||||
return this.paymentMethodRepository.find({
|
||||
where: { tenantId, isActive: true },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, id: string): Promise<PaymentMethod> {
|
||||
const method = await this.paymentMethodRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!method) {
|
||||
throw new NotFoundException('Método de pago no encontrado');
|
||||
}
|
||||
|
||||
return method;
|
||||
}
|
||||
|
||||
async getDefault(tenantId: string): Promise<PaymentMethod | null> {
|
||||
return this.paymentMethodRepository.findOne({
|
||||
where: { tenantId, isDefault: true, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async initializeForTenant(tenantId: string): Promise<PaymentMethod[]> {
|
||||
const existing = await this.paymentMethodRepository.count({
|
||||
where: { tenantId },
|
||||
});
|
||||
|
||||
if (existing > 0) {
|
||||
return this.findAll(tenantId);
|
||||
}
|
||||
|
||||
const methods: PaymentMethod[] = [];
|
||||
|
||||
for (const method of DEFAULT_PAYMENT_METHODS) {
|
||||
const paymentMethod = this.paymentMethodRepository.create({
|
||||
...method,
|
||||
tenantId,
|
||||
});
|
||||
methods.push(await this.paymentMethodRepository.save(paymentMethod));
|
||||
}
|
||||
|
||||
return methods;
|
||||
}
|
||||
|
||||
async toggleActive(tenantId: string, id: string): Promise<PaymentMethod> {
|
||||
const method = await this.findOne(tenantId, id);
|
||||
method.isActive = !method.isActive;
|
||||
return this.paymentMethodRepository.save(method);
|
||||
}
|
||||
|
||||
async setDefault(tenantId: string, id: string): Promise<PaymentMethod> {
|
||||
// Remove default from all
|
||||
await this.paymentMethodRepository.update(
|
||||
{ tenantId },
|
||||
{ isDefault: false },
|
||||
);
|
||||
|
||||
// Set new default
|
||||
const method = await this.findOne(tenantId, id);
|
||||
method.isDefault = true;
|
||||
return this.paymentMethodRepository.save(method);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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<Product>,
|
||||
@InjectRepository(Tenant)
|
||||
private readonly tenantRepository: Repository<Tenant>,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, filters: ProductFilterDto): Promise<Product[]> {
|
||||
const query = this.productRepository
|
||||
.createQueryBuilder('product')
|
||||
.leftJoinAndSelect('product.category', 'category')
|
||||
.where('product.tenantId = :tenantId', { tenantId });
|
||||
|
||||
if (filters.categoryId) {
|
||||
query.andWhere('product.categoryId = :categoryId', {
|
||||
categoryId: filters.categoryId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
query.andWhere(
|
||||
'(product.name ILIKE :search OR product.sku ILIKE :search OR product.barcode ILIKE :search)',
|
||||
{ search: `%${filters.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.favorites) {
|
||||
query.andWhere('product.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<Product> {
|
||||
const product = await this.productRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['category'],
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Producto no encontrado');
|
||||
}
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
async findByBarcode(tenantId: string, barcode: string): Promise<Product> {
|
||||
const product = await this.productRepository.findOne({
|
||||
where: { barcode, tenantId, isActive: true },
|
||||
relations: ['category'],
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Producto no encontrado');
|
||||
}
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateProductDto): Promise<Product> {
|
||||
// Check product limit
|
||||
const tenant = await this.tenantRepository.findOne({
|
||||
where: { id: tenantId },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new BadRequestException('Tenant no encontrado');
|
||||
}
|
||||
|
||||
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<Product> {
|
||||
const product = await this.findOne(tenantId, id);
|
||||
|
||||
// Check SKU uniqueness if changed
|
||||
if (dto.sku && dto.sku !== product.sku) {
|
||||
const existingSku = await this.productRepository.findOne({
|
||||
where: { tenantId, sku: dto.sku },
|
||||
});
|
||||
|
||||
if (existingSku) {
|
||||
throw new ConflictException('Ya existe un producto con ese SKU');
|
||||
}
|
||||
}
|
||||
|
||||
// Check barcode uniqueness if changed
|
||||
if (dto.barcode && dto.barcode !== product.barcode) {
|
||||
const existingBarcode = await this.productRepository.findOne({
|
||||
where: { tenantId, barcode: dto.barcode },
|
||||
});
|
||||
|
||||
if (existingBarcode) {
|
||||
throw new ConflictException('Ya existe un producto con ese código de barras');
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(product, dto);
|
||||
return this.productRepository.save(product);
|
||||
}
|
||||
|
||||
async delete(tenantId: string, id: string): Promise<void> {
|
||||
const product = await this.findOne(tenantId, id);
|
||||
await this.productRepository.remove(product);
|
||||
}
|
||||
|
||||
async toggleActive(tenantId: string, id: string): Promise<Product> {
|
||||
const product = await this.findOne(tenantId, id);
|
||||
product.isActive = !product.isActive;
|
||||
return this.productRepository.save(product);
|
||||
}
|
||||
|
||||
async toggleFavorite(tenantId: string, id: string): Promise<Product> {
|
||||
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<Product> {
|
||||
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<Product[]> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsUUID,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
Min,
|
||||
MaxLength,
|
||||
Matches,
|
||||
ArrayMinSize,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class SaleItemDto {
|
||||
@ApiProperty({
|
||||
description: 'ID del producto',
|
||||
required: true,
|
||||
})
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Cantidad vendida',
|
||||
example: 2,
|
||||
minimum: 0.001,
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
quantity: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Descuento en porcentaje (opcional)',
|
||||
example: 10,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
discountPercent?: number;
|
||||
}
|
||||
|
||||
export class CreateSaleDto {
|
||||
@ApiProperty({
|
||||
description: 'Lista de productos vendidos',
|
||||
type: [SaleItemDto],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1, { message: 'Debe incluir al menos un producto' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SaleItemDto)
|
||||
items: SaleItemDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID del método de pago',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
paymentMethodId?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Monto recibido del cliente',
|
||||
example: 100,
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amountReceived: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Nombre del cliente (opcional)',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
customerName?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Teléfono del cliente (opcional)',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[0-9]{10}$/, {
|
||||
message: 'El teléfono debe tener exactamente 10 dígitos',
|
||||
})
|
||||
customerPhone?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Notas adicionales',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Información del dispositivo (auto-llenado)',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
deviceInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class CancelSaleDto {
|
||||
@ApiProperty({
|
||||
description: 'Razón de la cancelación',
|
||||
example: 'Cliente cambió de opinión',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class SalesFilterDto {
|
||||
@ApiProperty({
|
||||
description: 'Fecha de inicio (YYYY-MM-DD)',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Fecha de fin (YYYY-MM-DD)',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endDate?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Estado de la venta',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Número de ticket',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ticketNumber?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Límite de resultados',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
limit?: number;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<string, unknown>;
|
||||
|
||||
@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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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<Sale>,
|
||||
@InjectRepository(SaleItem)
|
||||
private readonly saleItemRepository: Repository<SaleItem>,
|
||||
@InjectRepository(Product)
|
||||
private readonly productRepository: Repository<Product>,
|
||||
@InjectRepository(Tenant)
|
||||
private readonly tenantRepository: Repository<Tenant>,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, filters: SalesFilterDto): Promise<Sale[]> {
|
||||
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<Sale> {
|
||||
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<Sale> {
|
||||
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<Sale> {
|
||||
// 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<SaleItem>[] = [];
|
||||
|
||||
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<Sale> {
|
||||
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<TodaySummary> {
|
||||
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<Sale[]> {
|
||||
return this.saleRepository.find({
|
||||
where: { tenantId },
|
||||
relations: ['items', 'paymentMethod'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 $$;
|
||||
@ -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
|
||||
75
projects/erp-suite/apps/products/pos-micro/scripts/dev.sh
Executable file
75
projects/erp-suite/apps/products/pos-micro/scripts/dev.sh
Executable file
@ -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
|
||||
676
projects/erp-suite/apps/saas/billing/database/ddl/00-schema.sql
Normal file
676
projects/erp-suite/apps/saas/billing/database/ddl/00-schema.sql
Normal file
@ -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 $$;
|
||||
Loading…
Reference in New Issue
Block a user