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:
rckrdmrd 2025-12-08 11:48:07 -06:00
parent 2781837d9e
commit 9bfc6fb152
38 changed files with 4269 additions and 0 deletions

View File

@ -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

View File

@ -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"]

View File

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

View File

@ -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"
}
}

View File

@ -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 {}

View File

@ -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();

View File

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

View File

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

View File

@ -0,0 +1,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,
},
};
}
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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;
}

View File

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

View File

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

View File

@ -0,0 +1,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);
}
}

View File

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

View File

@ -0,0 +1,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);
}
}

View File

@ -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[];
}

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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;
}

View File

@ -0,0 +1,75 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Category } from '../../categories/entities/category.entity';
@Entity({ schema: '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;
}

View File

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

View File

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

View File

@ -0,0 +1,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();
}
}

View File

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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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,
});
}
}

View File

@ -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/*"]
}
}
}

View File

@ -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 $$;

View File

@ -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

View 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

View 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 $$;