diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..399544c --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# ============================================================================= +# SUBREPOSITORIOS - Tienen sus propios repositorios independientes +# ============================================================================= +backend/ +database/ + +# Dependencias +node_modules/ + +# Build +dist/ +build/ + +# Environment +.env +.env.local +!.env.example + +# Logs +*.log + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..9902c7d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +# ============================================================================= +# Subrepositorios de erp-retail +# Cada subproyecto tiene su propio repositorio para deployment independiente +# ============================================================================= + +[submodule "backend"] + path = backend + url = git@gitea-server:rckrdmrd/erp-retail-backend.git + +[submodule "database"] + path = database + url = git@gitea-server:rckrdmrd/erp-retail-database.git diff --git a/backend/docs/SPRINT-4-SUMMARY.md b/backend/docs/SPRINT-4-SUMMARY.md deleted file mode 100644 index 976c81c..0000000 --- a/backend/docs/SPRINT-4-SUMMARY.md +++ /dev/null @@ -1,225 +0,0 @@ -# Sprint 4: Motor de Precios - Resumen - -## Objetivo -Implementar el motor de precios completo para el vertical de Retail, incluyendo gestión de promociones, cupones y cálculo dinámico de precios. - -## Componentes Implementados - -### 1. Servicios - -#### PriceEngineService (`src/modules/pricing/services/price-engine.service.ts`) -Motor central de cálculo de precios con las siguientes capacidades: - -- **calculatePrices()**: Calcula precios para un conjunto de líneas de pedido - - Aplica promociones automáticas activas - - Soporta múltiples tipos de promoción - - Gestiona stackability (promociones acumulables) - - Aplica cupones si se proporciona código - -- **getActivePromotions()**: Obtiene promociones activas para sucursal/canal - -- **applyCoupon()**: Aplica un cupón al resultado de precios - -- **validateCoupon()**: Valida un código de cupón sin aplicarlo - -**Tipos de Promoción Soportados:** -| Tipo | Descripción | -|------|-------------| -| `percentage_discount` | Descuento porcentual | -| `fixed_discount` | Descuento de monto fijo | -| `buy_x_get_y` | Compra X, lleva Y | -| `bundle` | Precio especial por paquete | -| `flash_sale` | Venta relámpago | -| `quantity_discount` | Descuento por volumen | -| `free_shipping` | Envío gratis | -| `gift_with_purchase` | Regalo con compra | - -#### PromotionService (`src/modules/pricing/services/promotion.service.ts`) -Servicio CRUD para gestión de promociones: - -- **createPromotion()**: Crear promoción con validación de código único -- **updatePromotion()**: Actualizar promoción (solo en estado draft/scheduled) -- **listPromotions()**: Listar con filtros paginados -- **activatePromotion()**: Activar promoción -- **pausePromotion()**: Pausar promoción activa -- **endPromotion()**: Finalizar promoción -- **cancelPromotion()**: Cancelar promoción -- **addProducts()**: Agregar productos a promoción -- **removeProduct()**: Remover producto de promoción -- **updatePromotionStatuses()**: Actualizar estados automáticamente según fechas - -**Flujo de Estados:** -``` -draft → scheduled → active → ended - ↓ - paused - ↓ - cancelled -``` - -#### CouponService (`src/modules/pricing/services/coupon.service.ts`) -Servicio completo para gestión de cupones: - -- **createCoupon()**: Crear cupón individual -- **generateBulkCoupons()**: Generar cupones en lote (hasta 1000) -- **updateCoupon()**: Actualizar cupón -- **activateCoupon()**: Activar cupón -- **deactivateCoupon()**: Desactivar cupón -- **redeemCoupon()**: Canjear cupón con validación completa -- **reverseRedemption()**: Revertir un canje (devolución) -- **getRedemptionHistory()**: Historial de canjes paginado -- **getCouponStats()**: Estadísticas del cupón - -**Tipos de Cupón:** -- `percentage`: Descuento porcentual -- `fixed_amount`: Monto fijo -- `free_shipping`: Envío gratis -- `free_product`: Producto gratis - -### 2. Validación Zod (`src/modules/pricing/validation/pricing.schema.ts`) - -Schemas completos para: - -**Promociones:** -- `createPromotionSchema`: Creación con todas las opciones -- `updatePromotionSchema`: Actualización parcial -- `addPromotionProductsSchema`: Agregar productos -- `listPromotionsQuerySchema`: Filtros de listado - -**Cupones:** -- `createCouponSchema`: Creación con opciones completas -- `updateCouponSchema`: Actualización parcial -- `generateBulkCouponsSchema`: Generación en lote -- `redeemCouponSchema`: Canje de cupón -- `validateCouponSchema`: Validación de código -- `reverseRedemptionSchema`: Reversión de canje -- `listCouponsQuerySchema`: Filtros de listado - -**Motor de Precios:** -- `calculatePricesSchema`: Entrada para cálculo -- `lineItemSchema`: Línea de producto - -### 3. Controladores (`src/modules/pricing/controllers/pricing.controller.ts`) - -**Endpoints del Motor de Precios (3):** -| Método | Ruta | Descripción | -|--------|------|-------------| -| POST | `/api/pricing/calculate` | Calcular precios | -| POST | `/api/pricing/validate-coupon` | Validar cupón | -| GET | `/api/pricing/active-promotions` | Promociones activas | - -**Endpoints de Promociones (11):** -| Método | Ruta | Descripción | -|--------|------|-------------| -| GET | `/api/pricing/promotions` | Listar promociones | -| GET | `/api/pricing/promotions/:id` | Obtener promoción | -| POST | `/api/pricing/promotions` | Crear promoción | -| PUT | `/api/pricing/promotions/:id` | Actualizar promoción | -| POST | `/api/pricing/promotions/:id/activate` | Activar | -| POST | `/api/pricing/promotions/:id/pause` | Pausar | -| POST | `/api/pricing/promotions/:id/end` | Finalizar | -| POST | `/api/pricing/promotions/:id/cancel` | Cancelar | -| POST | `/api/pricing/promotions/:id/products` | Agregar productos | -| DELETE | `/api/pricing/promotions/:id/products/:productId` | Remover producto | -| DELETE | `/api/pricing/promotions/:id` | Eliminar (soft) | - -**Endpoints de Cupones (14):** -| Método | Ruta | Descripción | -|--------|------|-------------| -| GET | `/api/pricing/coupons` | Listar cupones | -| GET | `/api/pricing/coupons/:id` | Obtener cupón | -| GET | `/api/pricing/coupons/code/:code` | Obtener por código | -| POST | `/api/pricing/coupons` | Crear cupón | -| POST | `/api/pricing/coupons/bulk` | Generar lote | -| PUT | `/api/pricing/coupons/:id` | Actualizar cupón | -| POST | `/api/pricing/coupons/:id/activate` | Activar | -| POST | `/api/pricing/coupons/:id/deactivate` | Desactivar | -| POST | `/api/pricing/coupons/:id/redeem` | Canjear por ID | -| POST | `/api/pricing/coupons/redeem` | Canjear por código | -| POST | `/api/pricing/coupons/redemptions/:id/reverse` | Revertir canje | -| GET | `/api/pricing/coupons/:id/redemptions` | Historial canjes | -| GET | `/api/pricing/coupons/:id/stats` | Estadísticas | -| DELETE | `/api/pricing/coupons/:id` | Eliminar (soft) | - -**Total: 28 endpoints** - -### 4. Rutas (`src/modules/pricing/routes/pricing.routes.ts`) - -Configuración de rutas con: -- Autenticación JWT requerida -- Middleware de sucursal donde aplica -- Validación Zod de entrada -- Control de permisos granular - -**Permisos Requeridos:** -- `pricing:calculate`, `pricing:validate`, `pricing:view` -- `promotions:view`, `promotions:create`, `promotions:update`, `promotions:activate`, `promotions:delete` -- `coupons:view`, `coupons:create`, `coupons:update`, `coupons:activate`, `coupons:redeem`, `coupons:reverse`, `coupons:delete` - -## Características Destacadas - -### Restricciones de Promociones -- Por fechas (inicio/fin) -- Por días de la semana -- Por horarios específicos -- Por monto mínimo de orden -- Por cantidad mínima de productos -- Por categorías incluidas/excluidas -- Por productos excluidos -- Por sucursales específicas -- Por niveles de cliente -- Solo nuevos clientes -- Solo miembros de lealtad - -### Stackability (Acumulabilidad) -- Promociones pueden ser acumulables o exclusivas -- Lista de promociones con las que puede combinarse -- Prioridad numérica para resolver conflictos - -### Tracking de Cupones -- Límite de usos totales -- Límite de usos por cliente -- Tracking de canjes con orden y canal -- Posibilidad de reversión con razón - -## Estructura de Archivos - -``` -src/modules/pricing/ -├── controllers/ -│ ├── index.ts -│ └── pricing.controller.ts -├── entities/ -│ ├── index.ts -│ ├── promotion.entity.ts -│ ├── promotion-product.entity.ts -│ ├── coupon.entity.ts -│ └── coupon-redemption.entity.ts -├── routes/ -│ ├── index.ts -│ └── pricing.routes.ts -├── services/ -│ ├── index.ts -│ ├── price-engine.service.ts -│ ├── promotion.service.ts -│ └── coupon.service.ts -├── validation/ -│ ├── index.ts -│ └── pricing.schema.ts -└── index.ts -``` - -## Dependencias - -- TypeORM 0.3.x -- Zod para validación -- Express para routing -- Entidades existentes: Promotion, Coupon, PromotionProduct, CouponRedemption - -## Próximos Pasos - -El Sprint 5 cubrirá CFDI/Facturación según el roadmap: -- Integración con PAC para timbrado -- Generación de XML CFDI 4.0 -- Cancelación de facturas -- Notas de crédito diff --git a/backend/docs/SPRINT-5-SUMMARY.md b/backend/docs/SPRINT-5-SUMMARY.md deleted file mode 100644 index dc2e80c..0000000 --- a/backend/docs/SPRINT-5-SUMMARY.md +++ /dev/null @@ -1,232 +0,0 @@ -# Sprint 5: CFDI/Facturación - Resumen - -## Objetivo -Implementar el sistema completo de facturación electrónica CFDI 4.0 para el vertical de Retail, con integración a PACs y soporte para autofactura. - -## Componentes Implementados - -### 1. Servicios - -#### XMLService (`src/modules/invoicing/services/xml.service.ts`) -Servicio para construcción y manipulación de XML CFDI 4.0: - -- **buildCFDIXML()**: Construye XML completo según especificación SAT -- **addSealToXML()**: Agrega sello del contribuyente -- **addCertificateToXML()**: Agrega certificado CSD -- **addTimbreToXML()**: Agrega complemento TimbreFiscalDigital -- **buildCadenaOriginal()**: Genera cadena original para firma -- **parseStampedXML()**: Parsea respuesta de PAC -- **generateQRData()**: Genera URL de verificación SAT para QR -- **validateXMLStructure()**: Validación básica de estructura - -**Características:** -- Namespaces CFDI 4.0 y TFD 1.1 -- Soporte para CFDIs relacionados -- Formateo de montos y tasas según SAT -- Generación de cadena original del SAT - -#### PACService (`src/modules/invoicing/services/pac.service.ts`) -Servicio de integración con Proveedores Autorizados de Certificación: - -- **stampCFDI()**: Timbrado de CFDI -- **cancelCFDI()**: Cancelación con motivo -- **verifyCFDIStatus()**: Consulta estado en SAT -- **encrypt()/decrypt()**: Cifrado de credenciales -- **loadCertificate()**: Carga de CSD -- **signData()**: Firma con llave privada - -**PACs Soportados:** -| PAC | Timbrado | Cancelación | Verificación | -|-----|----------|-------------|--------------| -| Finkok | ✓ | ✓ | ✓ | -| Facturapi | ✓ | ✓ | - | -| SW Sapien | ✓ | - | - | -| Soluciones Fiscales | Config | Config | Config | -| Digisat | Config | Config | Config | - -**Motivos de Cancelación (SAT):** -- `01`: Comprobante emitido con errores con relación -- `02`: Comprobante emitido con errores sin relación -- `03`: No se llevó a cabo la operación -- `04`: Operación nominativa relacionada en factura global - -#### CFDIBuilderService (`src/modules/invoicing/services/cfdi-builder.service.ts`) -Constructor de estructuras CFDI desde datos de orden: - -- **buildFromOrder()**: Construye CFDI desde líneas de pedido -- **buildCreditNote()**: Genera nota de crédito -- **buildPaymentComplement()**: Genera complemento de pago -- **validateReceptor()**: Valida datos del receptor (CFDI 4.0) -- **getUsoCFDIOptions()**: Opciones de uso CFDI por tipo de persona -- **getCommonClavesProdServ()**: Claves SAT comunes para retail - -**Cálculos Automáticos:** -- Desglose de impuestos (IVA, ISR, IEPS) -- Agregación por tipo de impuesto -- Manejo de precios con/sin impuesto -- Retenciones IVA e ISR - -#### CFDIService (`src/modules/invoicing/services/cfdi.service.ts`) -Servicio principal de facturación: - -- **createCFDI()**: Crear factura (draft o timbrada) -- **stampCFDI()**: Timbrar factura en borrador -- **cancelCFDI()**: Cancelar factura timbrada -- **verifyCFDIStatus()**: Verificar estado en SAT -- **listCFDIs()**: Listado con filtros paginados -- **getCFDIById()**: Obtener por ID -- **getCFDIByUUID()**: Obtener por UUID -- **downloadXML()**: Descargar XML -- **resendEmail()**: Reenviar por correo -- **createCreditNote()**: Crear nota de crédito -- **getStats()**: Estadísticas de facturación -- **getActiveConfig()**: Obtener configuración activa - -### 2. Validación Zod (`src/modules/invoicing/validation/cfdi.schema.ts`) - -**Schemas de CFDI:** -- `receptorSchema`: Datos del receptor con validación RFC -- `orderLineSchema`: Líneas con claves SAT -- `createCFDISchema`: Creación completa -- `cancelCFDISchema`: Cancelación con motivo -- `listCFDIsQuerySchema`: Filtros de búsqueda -- `createCreditNoteSchema`: Nota de crédito - -**Schemas de Configuración:** -- `createCFDIConfigSchema`: Configuración PAC y CSD -- `updateCFDIConfigSchema`: Actualización parcial -- `validateCFDIConfigSchema`: Validación de config - -**Schemas de Autofactura:** -- `autofacturaRequestSchema`: Solicitud de autofactura -- `lookupOrderSchema`: Búsqueda de ticket - -### 3. Controladores (`src/modules/invoicing/controllers/cfdi.controller.ts`) - -**Endpoints de CFDI (14):** -| Método | Ruta | Descripción | -|--------|------|-------------| -| GET | `/cfdis` | Listar CFDIs | -| GET | `/cfdis/:id` | Obtener por ID | -| GET | `/cfdis/uuid/:uuid` | Obtener por UUID | -| POST | `/cfdis` | Crear CFDI | -| POST | `/cfdis/:id/stamp` | Timbrar | -| POST | `/cfdis/:id/cancel` | Cancelar | -| GET | `/cfdis/:id/verify` | Verificar en SAT | -| GET | `/cfdis/:id/xml` | Descargar XML | -| POST | `/cfdis/:id/resend-email` | Reenviar email | -| POST | `/cfdis/:id/credit-note` | Crear nota crédito | -| GET | `/stats` | Estadísticas | -| GET | `/cancellation-reasons` | Motivos cancelación | -| GET | `/uso-cfdi-options` | Opciones UsoCFDI | -| GET | `/claves-prod-serv` | Claves SAT | - -**Endpoints de Configuración (5):** -| Método | Ruta | Descripción | -|--------|------|-------------| -| GET | `/config` | Obtener config | -| POST | `/config` | Crear/actualizar config | -| PUT | `/config` | Actualizar config | -| POST | `/config/certificate` | Subir CSD | -| POST | `/config/validate` | Validar config | - -**Endpoints de Autofactura (2):** -| Método | Ruta | Descripción | -|--------|------|-------------| -| POST | `/autofactura/lookup` | Buscar ticket | -| POST | `/autofactura/create` | Generar factura | - -**Total: 21 endpoints** - -### 4. Rutas (`src/modules/invoicing/routes/cfdi.routes.ts`) - -**Rutas Públicas:** -- Autofactura (lookup, create) -- Catálogos SAT (uso CFDI, claves prod/serv, motivos cancelación) - -**Rutas Autenticadas:** -- CFDI CRUD y operaciones -- Configuración (requiere `cfdi:config:*`) - -**Permisos Requeridos:** -- `cfdi:view`, `cfdi:create`, `cfdi:stamp`, `cfdi:cancel`, `cfdi:send` -- `cfdi:config:view`, `cfdi:config:manage` - -## Estructura de Archivos - -``` -src/modules/invoicing/ -├── controllers/ -│ ├── index.ts -│ └── cfdi.controller.ts -├── entities/ -│ ├── index.ts -│ ├── cfdi.entity.ts -│ └── cfdi-config.entity.ts -├── routes/ -│ ├── index.ts -│ └── cfdi.routes.ts -├── services/ -│ ├── index.ts -│ ├── cfdi.service.ts -│ ├── cfdi-builder.service.ts -│ ├── xml.service.ts -│ └── pac.service.ts -├── validation/ -│ ├── index.ts -│ └── cfdi.schema.ts -└── index.ts -``` - -## Flujo de Facturación - -``` -┌────────────┐ ┌──────────────┐ ┌─────────────┐ -│ Order │────▶│ CFDIBuilder │────▶│ CFDI │ -│ Data │ │ Service │ │ (draft) │ -└────────────┘ └──────────────┘ └──────┬──────┘ - │ - ▼ -┌────────────┐ ┌──────────────┐ ┌─────────────┐ -│ Stamped │◀────│ PAC │◀────│ XML │ -│ CFDI │ │ Service │ │ Service │ -└────────────┘ └──────────────┘ └─────────────┘ -``` - -## Estados del CFDI - -``` -draft ──────▶ pending ──────▶ stamped ──────▶ cancelled - │ │ - ▼ ▼ - error cancellation_pending -``` - -## Características CFDI 4.0 - -- Validación de RFC y régimen fiscal del receptor -- Código postal del domicilio fiscal requerido -- Objeto de impuesto por concepto -- Relacionados con tipo de relación -- Exportación (01 = No exportación) - -## Dependencias - -- xmlbuilder2: Construcción de XML -- crypto (Node.js): Cifrado y firma -- TypeORM 0.3.x -- Zod para validación - -## Variables de Entorno Requeridas - -```env -PAC_ENCRYPTION_KEY=your-32-byte-encryption-key -``` - -## Próximos Pasos - -El Sprint 6 según el roadmap cubrirá POS Backend: -- Sesiones de POS -- Órdenes y líneas -- Pagos múltiples -- Integración con motor de precios diff --git a/backend/docs/SPRINT-6-SUMMARY.md b/backend/docs/SPRINT-6-SUMMARY.md deleted file mode 100644 index 9f8ade8..0000000 --- a/backend/docs/SPRINT-6-SUMMARY.md +++ /dev/null @@ -1,216 +0,0 @@ -# Sprint 6: POS Backend - Resumen - -## Objetivo -Completar y mejorar el módulo de Punto de Venta (POS) con funcionalidades avanzadas: reembolsos, órdenes en espera (hold/recall), integración con cupones y estadísticas de sesión. - -## Estado Inicial -El módulo POS ya contaba con funcionalidades base: -- Apertura/cierre de sesiones -- Creación de órdenes y líneas -- Procesamiento de pagos múltiples -- Anulación de órdenes -- Búsqueda de órdenes - -## Componentes Mejorados - -### 1. POSOrderService (Mejoras) - -#### Nuevos Métodos de Reembolso: -- **createRefund()**: Crea orden de reembolso desde orden original - - Valida orden original pagada - - Valida cantidades a reembolsar - - Genera líneas negativas - - Relaciona con orden original - -- **processRefundPayment()**: Procesa pago de reembolso - - Genera movimiento de efectivo negativo - - Actualiza totales de sesión - - Marca reembolso como completado - -#### Nuevos Métodos Hold/Recall: -- **holdOrder()**: Pone orden en espera ("parking") - - Solo aplica a órdenes en draft - - Guarda nombre/referencia de la orden - - Cambia estado a confirmado con metadata - -- **getHeldOrders()**: Lista órdenes en espera por sucursal - -- **recallOrder()**: Recupera orden en espera - - Asigna a sesión/caja actual - - Vuelve a estado draft - -#### Métodos Adicionales: -- **applyCouponToOrder()**: Aplica cupón a orden -- **markReceiptPrinted()**: Marca ticket como impreso -- **sendReceiptEmail()**: Envía ticket por correo -- **getSessionStats()**: Estadísticas de sesión - -### 2. Controladores Nuevos - -**Endpoints de Reembolso (2):** -| Método | Ruta | Descripción | Permisos | -|--------|------|-------------|----------| -| POST | `/pos/refunds` | Crear reembolso | supervisor+ | -| POST | `/pos/refunds/:id/process` | Procesar pago | supervisor+ | - -**Endpoints Hold/Recall (3):** -| Método | Ruta | Descripción | -|--------|------|-------------| -| POST | `/pos/orders/:id/hold` | Poner en espera | -| GET | `/pos/orders/held` | Listar en espera | -| POST | `/pos/orders/:id/recall` | Recuperar orden | - -**Endpoints Adicionales (4):** -| Método | Ruta | Descripción | -|--------|------|-------------| -| POST | `/pos/orders/:id/coupon` | Aplicar cupón | -| POST | `/pos/orders/:id/receipt/print` | Marcar impreso | -| POST | `/pos/orders/:id/receipt/send` | Enviar email | -| GET | `/pos/sessions/:id/stats` | Estadísticas | - -**Total nuevos endpoints: 9** -**Total endpoints POS: 21** - -### 3. Validación Zod (Nuevos Schemas) - -```typescript -// Refunds -refundLineSchema -createRefundSchema -processRefundPaymentSchema - -// Hold/Recall -holdOrderSchema -recallOrderSchema - -// Coupon -applyCouponSchema -``` - -## Estructura de Archivos - -``` -src/modules/pos/ -├── controllers/ -│ └── pos.controller.ts (605 líneas) -├── entities/ -│ ├── index.ts -│ ├── pos-session.entity.ts -│ ├── pos-order.entity.ts -│ ├── pos-order-line.entity.ts -│ └── pos-payment.entity.ts -├── routes/ -│ └── pos.routes.ts (143 líneas) -├── services/ -│ ├── index.ts -│ ├── pos-session.service.ts (361 líneas) -│ └── pos-order.service.ts (915 líneas) -├── validation/ -│ └── pos.schema.ts (207 líneas) -└── index.ts -``` - -## Flujos de Negocio - -### Flujo de Reembolso -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Original │────▶│ Create │────▶│ Refund │ -│ Order │ │ Refund │ │ (draft) │ -│ (paid) │ │ │ │ │ -└─────────────┘ └─────────────┘ └──────┬──────┘ - │ - ▼ - ┌─────────────┐ ┌─────────────┐ - │ Refund │◀────│ Process │ - │ (paid) │ │ Payment │ - └─────────────┘ └─────────────┘ -``` - -### Flujo Hold/Recall -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Order │────▶│ Hold │────▶│ Order │ -│ (draft) │ │ Order │ │ (held) │ -└─────────────┘ └─────────────┘ └──────┬──────┘ - │ - ┌────────────────────────────────────────┘ - │ - ▼ -┌─────────────┐ ┌─────────────┐ -│ Recall │────▶│ Order │ -│ Order │ │ (draft) │ -└─────────────┘ └─────────────┘ -``` - -## Tipos de Orden - -| Tipo | Código | Descripción | -|------|--------|-------------| -| SALE | sale | Venta normal | -| REFUND | refund | Devolución | -| EXCHANGE | exchange | Cambio | - -## Estados de Orden - -| Estado | Descripción | -|--------|-------------| -| DRAFT | Orden en proceso | -| CONFIRMED | Confirmada / En espera (hold) | -| PAID | Pagada completamente | -| PARTIALLY_PAID | Pago parcial | -| VOIDED | Anulada | -| REFUNDED | Reembolsada | - -## Permisos Requeridos - -| Acción | Roles Permitidos | -|--------|------------------| -| Crear orden | Todos autenticados | -| Agregar pago | Todos autenticados | -| Anular orden | admin, manager, supervisor | -| Crear reembolso | admin, manager, supervisor | -| Procesar reembolso | admin, manager, supervisor | -| Hold/Recall | Todos autenticados | - -## Estadísticas de Sesión - -El endpoint `/pos/sessions/:id/stats` devuelve: -```typescript -{ - totalOrders: number; // Órdenes completadas - totalSales: number; // Ventas totales - totalRefunds: number; // Reembolsos totales - totalDiscounts: number; // Descuentos aplicados - averageTicket: number; // Ticket promedio - paymentBreakdown: { // Desglose por método - cash: number; - credit_card: number; - debit_card: number; - // ... - }; -} -``` - -## Integración Pendiente - -- [ ] Integración completa con PriceEngineService para cupones -- [ ] Envío real de email para receipts -- [ ] Generación de PDF de ticket -- [ ] Integración con impresora térmica - -## Dependencias - -- TypeORM 0.3.x -- Zod para validación -- BaseService para operaciones CRUD -- Middleware de autenticación y roles - -## Próximos Pasos - -Sprint 7 según roadmap: POS Frontend -- Layout de interfaz POS -- Pantalla de ventas -- Pantalla de cobro -- Componentes de producto -- Componentes de pago diff --git a/backend/package.json b/backend/package.json deleted file mode 100644 index 8532620..0000000 --- a/backend/package.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "name": "@erp-suite/retail-backend", - "version": "0.1.0", - "description": "Backend for ERP Retail vertical - Point of Sale, Inventory, E-commerce", - "main": "dist/index.js", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc", - "start": "node dist/index.js", - "lint": "eslint src --ext .ts", - "typecheck": "tsc --noEmit", - "test": "jest", - "typeorm": "typeorm-ts-node-commonjs", - "migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.ts", - "migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.ts", - "migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.ts" - }, - "dependencies": { - "express": "^4.18.2", - "cors": "^2.8.5", - "helmet": "^7.1.0", - "compression": "^1.7.4", - "morgan": "^1.10.0", - "dotenv": "^16.3.1", - "pg": "^8.11.3", - "typeorm": "^0.3.28", - "reflect-metadata": "^0.2.2", - "ioredis": "^5.8.2", - "jsonwebtoken": "^9.0.2", - "bcryptjs": "^2.4.3", - "uuid": "^9.0.1", - "zod": "^3.22.4", - "winston": "^3.11.0", - "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1", - "socket.io": "^4.7.4" - }, - "devDependencies": { - "@types/express": "^4.17.21", - "@types/cors": "^2.8.17", - "@types/compression": "^1.7.5", - "@types/morgan": "^1.9.9", - "@types/node": "^20.10.6", - "@types/jsonwebtoken": "^9.0.5", - "@types/bcryptjs": "^2.4.6", - "@types/uuid": "^9.0.7", - "@types/swagger-jsdoc": "^6.0.4", - "@types/swagger-ui-express": "^4.1.6", - "typescript": "^5.3.3", - "tsx": "^4.6.2", - "eslint": "^8.56.0", - "@typescript-eslint/eslint-plugin": "^6.18.1", - "@typescript-eslint/parser": "^6.18.1", - "jest": "^29.7.0", - "ts-jest": "^29.1.1", - "@types/jest": "^29.5.11" - }, - "engines": { - "node": ">=20.0.0" - } -} diff --git a/backend/src/app.ts b/backend/src/app.ts deleted file mode 100644 index c958da4..0000000 --- a/backend/src/app.ts +++ /dev/null @@ -1,120 +0,0 @@ -import 'reflect-metadata'; -import express, { Application, Request, Response, NextFunction } from 'express'; -import cors from 'cors'; -import helmet from 'helmet'; -import compression from 'compression'; -import morgan from 'morgan'; - -// Route imports -import branchRoutes from './modules/branches/routes/branch.routes'; -import posRoutes from './modules/pos/routes/pos.routes'; - -// Error type -interface AppError extends Error { - statusCode?: number; - status?: string; - isOperational?: boolean; -} - -// Create Express app -const app: Application = express(); - -// Security middleware -app.use(helmet()); - -// CORS configuration -app.use(cors({ - origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000', 'http://localhost:5173'], - credentials: true, - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Tenant-ID', 'X-Branch-ID'], -})); - -// Compression -app.use(compression()); - -// Body parsing -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: true, limit: '10mb' })); - -// Request logging -if (process.env.NODE_ENV !== 'test') { - app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); -} - -// Health check endpoint -app.get('/health', (_req: Request, res: Response) => { - res.json({ - status: 'healthy', - timestamp: new Date().toISOString(), - version: process.env.npm_package_version || '0.1.0', - environment: process.env.NODE_ENV || 'development', - }); -}); - -// API version info -app.get('/api', (_req: Request, res: Response) => { - res.json({ - name: 'ERP Retail API', - version: '0.1.0', - modules: [ - 'branches', - 'pos', - 'cash', - 'inventory', - 'customers', - 'pricing', - 'invoicing', - 'ecommerce', - 'purchases', - ], - }); -}); - -// API Routes -app.use('/api/branches', branchRoutes); -app.use('/api/pos', posRoutes); -// TODO: Add remaining route modules as they are implemented -// app.use('/api/cash', cashRouter); -// app.use('/api/inventory', inventoryRouter); -// app.use('/api/customers', customersRouter); -// app.use('/api/pricing', pricingRouter); -// app.use('/api/invoicing', invoicingRouter); -// app.use('/api/ecommerce', ecommerceRouter); -// app.use('/api/purchases', purchasesRouter); - -// 404 handler -app.use((_req: Request, res: Response) => { - res.status(404).json({ - success: false, - error: { - code: 'NOT_FOUND', - message: 'The requested resource was not found', - }, - }); -}); - -// Global error handler -app.use((err: AppError, _req: Request, res: Response, _next: NextFunction) => { - const statusCode = err.statusCode || 500; - const status = err.status || 'error'; - - // Log error - console.error('Error:', { - message: err.message, - stack: process.env.NODE_ENV === 'development' ? err.stack : undefined, - statusCode, - }); - - // Send response - res.status(statusCode).json({ - success: false, - error: { - code: status.toUpperCase().replace(/ /g, '_'), - message: err.isOperational ? err.message : 'An unexpected error occurred', - ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), - }, - }); -}); - -export default app; diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts deleted file mode 100644 index f70a136..0000000 --- a/backend/src/config/database.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Pool, PoolClient, QueryResult } from 'pg'; -import dotenv from 'dotenv'; - -dotenv.config(); - -const pool = new Pool({ - host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '5432'), - database: process.env.DB_NAME || 'erp_retail', - user: process.env.DB_USER || 'retail_admin', - password: process.env.DB_PASSWORD, - max: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 2000, -}); - -pool.on('error', (err) => { - console.error('Unexpected error on idle client', err); - process.exit(-1); -}); - -export async function query( - text: string, - params?: any[] -): Promise> { - const start = Date.now(); - const res = await pool.query(text, params); - const duration = Date.now() - start; - - if (process.env.NODE_ENV === 'development') { - console.log('executed query', { text: text.substring(0, 100), duration, rows: res.rowCount }); - } - - return res; -} - -export async function queryOne( - text: string, - params?: any[] -): Promise { - const res = await query(text, params); - return res.rows[0] || null; -} - -export async function getClient(): Promise { - const client = await pool.connect(); - const release = client.release.bind(client); - - // Override release to track slow queries - client.release = () => { - release(); - }; - - return client; -} - -export async function transaction( - callback: (client: PoolClient) => Promise -): Promise { - const client = await getClient(); - - try { - await client.query('BEGIN'); - const result = await callback(client); - await client.query('COMMIT'); - return result; - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } -} - -export async function setTenantContext( - client: PoolClient, - tenantId: string -): Promise { - await client.query( - `SELECT set_config('app.current_tenant_id', $1, true)`, - [tenantId] - ); -} - -export async function initDatabase(): Promise { - try { - const client = await pool.connect(); - await client.query('SELECT NOW()'); - client.release(); - console.log('PostgreSQL pool connected successfully'); - } catch (error) { - console.error('Failed to connect to PostgreSQL:', error); - throw error; - } -} - -export async function closeDatabase(): Promise { - await pool.end(); - console.log('PostgreSQL pool closed'); -} - -export { pool }; diff --git a/backend/src/config/typeorm.ts b/backend/src/config/typeorm.ts deleted file mode 100644 index 3379dc5..0000000 --- a/backend/src/config/typeorm.ts +++ /dev/null @@ -1,144 +0,0 @@ -import 'reflect-metadata'; -import { DataSource, DataSourceOptions } from 'typeorm'; -import dotenv from 'dotenv'; - -// Entities - Branches -import { Branch } from '../modules/branches/entities/branch.entity'; -import { CashRegister } from '../modules/branches/entities/cash-register.entity'; -import { BranchUser } from '../modules/branches/entities/branch-user.entity'; - -// Entities - POS -import { POSSession } from '../modules/pos/entities/pos-session.entity'; -import { POSOrder } from '../modules/pos/entities/pos-order.entity'; -import { POSOrderLine } from '../modules/pos/entities/pos-order-line.entity'; -import { POSPayment } from '../modules/pos/entities/pos-payment.entity'; - -// Entities - Cash -import { CashMovement } from '../modules/cash/entities/cash-movement.entity'; -import { CashClosing } from '../modules/cash/entities/cash-closing.entity'; -import { CashCount } from '../modules/cash/entities/cash-count.entity'; - -// Entities - Inventory -import { StockTransfer } from '../modules/inventory/entities/stock-transfer.entity'; -import { StockTransferLine } from '../modules/inventory/entities/stock-transfer-line.entity'; -import { StockAdjustment } from '../modules/inventory/entities/stock-adjustment.entity'; -import { StockAdjustmentLine } from '../modules/inventory/entities/stock-adjustment-line.entity'; - -// Entities - Customers -import { LoyaltyProgram } from '../modules/customers/entities/loyalty-program.entity'; -import { MembershipLevel } from '../modules/customers/entities/membership-level.entity'; -import { LoyaltyTransaction } from '../modules/customers/entities/loyalty-transaction.entity'; -import { CustomerMembership } from '../modules/customers/entities/customer-membership.entity'; - -// Entities - Pricing -import { Promotion } from '../modules/pricing/entities/promotion.entity'; -import { PromotionProduct } from '../modules/pricing/entities/promotion-product.entity'; -import { Coupon } from '../modules/pricing/entities/coupon.entity'; -import { CouponRedemption } from '../modules/pricing/entities/coupon-redemption.entity'; - -// Entities - Invoicing -import { CFDIConfig } from '../modules/invoicing/entities/cfdi-config.entity'; -import { CFDI } from '../modules/invoicing/entities/cfdi.entity'; - -// Entities - E-commerce -import { Cart } from '../modules/ecommerce/entities/cart.entity'; -import { CartItem } from '../modules/ecommerce/entities/cart-item.entity'; -import { EcommerceOrder } from '../modules/ecommerce/entities/ecommerce-order.entity'; -import { EcommerceOrderLine } from '../modules/ecommerce/entities/ecommerce-order-line.entity'; -import { ShippingRate } from '../modules/ecommerce/entities/shipping-rate.entity'; - -// Entities - Purchases -import { PurchaseSuggestion } from '../modules/purchases/entities/purchase-suggestion.entity'; -import { SupplierOrder } from '../modules/purchases/entities/supplier-order.entity'; -import { SupplierOrderLine } from '../modules/purchases/entities/supplier-order-line.entity'; -import { GoodsReceipt } from '../modules/purchases/entities/goods-receipt.entity'; -import { GoodsReceiptLine } from '../modules/purchases/entities/goods-receipt-line.entity'; - -dotenv.config(); - -const isProduction = process.env.NODE_ENV === 'production'; - -const baseConfig: DataSourceOptions = { - type: 'postgres', - host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '5432'), - username: process.env.DB_USER || 'retail_admin', - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME || 'erp_retail', - schema: 'retail', - entities: [ - // Branches - Branch, - CashRegister, - BranchUser, - // POS - POSSession, - POSOrder, - POSOrderLine, - POSPayment, - // Cash - CashMovement, - CashClosing, - CashCount, - // Inventory - StockTransfer, - StockTransferLine, - StockAdjustment, - StockAdjustmentLine, - // Customers - LoyaltyProgram, - MembershipLevel, - LoyaltyTransaction, - CustomerMembership, - // Pricing - Promotion, - PromotionProduct, - Coupon, - CouponRedemption, - // Invoicing - CFDIConfig, - CFDI, - // E-commerce - Cart, - CartItem, - EcommerceOrder, - EcommerceOrderLine, - ShippingRate, - // Purchases - PurchaseSuggestion, - SupplierOrder, - SupplierOrderLine, - GoodsReceipt, - GoodsReceiptLine, - ], - migrations: ['src/migrations/*.ts'], - synchronize: false, // NEVER use in production - logging: isProduction ? ['error'] : ['query', 'error'], - maxQueryExecutionTime: 1000, // Log slow queries - extra: { - max: 10, - idleTimeoutMillis: 30000, - }, -}; - -export const AppDataSource = new DataSource(baseConfig); - -export async function initTypeORM(): Promise { - try { - if (!AppDataSource.isInitialized) { - await AppDataSource.initialize(); - console.log('TypeORM DataSource initialized successfully'); - } - return AppDataSource; - } catch (error) { - console.error('Failed to initialize TypeORM:', error); - throw error; - } -} - -export async function closeTypeORM(): Promise { - if (AppDataSource.isInitialized) { - await AppDataSource.destroy(); - console.log('TypeORM DataSource closed'); - } -} diff --git a/backend/src/index.ts b/backend/src/index.ts deleted file mode 100644 index 8235774..0000000 --- a/backend/src/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -import 'reflect-metadata'; -import dotenv from 'dotenv'; - -// Load environment variables first -dotenv.config(); - -import app from './app'; -import { initDatabase, closeDatabase } from './config/database'; -import { initTypeORM, closeTypeORM } from './config/typeorm'; - -const PORT = parseInt(process.env.PORT || '3001', 10); -const HOST = process.env.HOST || '0.0.0.0'; - -async function bootstrap(): Promise { - try { - console.log('Starting ERP Retail Backend...'); - console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); - - // Initialize database connections - console.log('Initializing database connections...'); - await initDatabase(); - await initTypeORM(); - - // Start server - const server = app.listen(PORT, HOST, () => { - console.log(`Server running on http://${HOST}:${PORT}`); - console.log(`Health check: http://${HOST}:${PORT}/health`); - console.log(`API info: http://${HOST}:${PORT}/api`); - }); - - // Graceful shutdown handlers - const shutdown = async (signal: string): Promise => { - console.log(`\n${signal} received. Starting graceful shutdown...`); - - server.close(async () => { - console.log('HTTP server closed'); - - try { - await closeTypeORM(); - await closeDatabase(); - console.log('All connections closed'); - process.exit(0); - } catch (error) { - console.error('Error during shutdown:', error); - process.exit(1); - } - }); - - // Force shutdown after 30 seconds - setTimeout(() => { - console.error('Forced shutdown after timeout'); - process.exit(1); - }, 30000); - }; - - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); - - // Handle uncaught errors - process.on('uncaughtException', (error) => { - console.error('Uncaught Exception:', error); - shutdown('uncaughtException'); - }); - - process.on('unhandledRejection', (reason, promise) => { - console.error('Unhandled Rejection at:', promise, 'reason:', reason); - }); - - } catch (error) { - console.error('Failed to start server:', error); - process.exit(1); - } -} - -// Start the application -bootstrap(); diff --git a/backend/src/modules/branches/controllers/branch.controller.ts b/backend/src/modules/branches/controllers/branch.controller.ts deleted file mode 100644 index 5e9a849..0000000 --- a/backend/src/modules/branches/controllers/branch.controller.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { Response, NextFunction } from 'express'; -import { BaseController } from '../../../shared/controllers/base.controller'; -import { AuthenticatedRequest } from '../../../shared/types'; -import { branchService } from '../services/branch.service'; -import { BranchStatus, BranchType } from '../entities/branch.entity'; - -class BranchController extends BaseController { - /** - * GET /branches - List all branches - */ - async list(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const pagination = this.parsePagination(req.query); - - const { status, type, search } = req.query; - - const result = await branchService.findAll(tenantId, { - pagination, - filters: [ - ...(status ? [{ field: 'status', operator: 'eq' as const, value: status }] : []), - ...(type ? [{ field: 'type', operator: 'eq' as const, value: type }] : []), - ], - search: search - ? { - fields: ['name', 'code', 'city'], - term: search as string, - } - : undefined, - }); - - return this.paginated(res, result); - } catch (error) { - next(error); - } - } - - /** - * GET /branches/active - List active branches - */ - async listActive(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const branches = await branchService.findActiveBranches(tenantId); - return this.success(res, branches); - } catch (error) { - next(error); - } - } - - /** - * GET /branches/:id - Get branch by ID - */ - async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { id } = req.params; - - const branch = await branchService.findById(tenantId, id, ['cashRegisters']); - - if (!branch) { - return this.notFound(res, 'Branch'); - } - - return this.success(res, branch); - } catch (error) { - next(error); - } - } - - /** - * GET /branches/code/:code - Get branch by code - */ - async getByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { code } = req.params; - - const branch = await branchService.findByCode(tenantId, code); - - if (!branch) { - return this.notFound(res, 'Branch'); - } - - return this.success(res, branch); - } catch (error) { - next(error); - } - } - - /** - * POST /branches - Create branch - */ - async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - - const result = await branchService.createWithRegister(tenantId, req.body, userId); - - if (!result.success) { - if (result.error.code === 'DUPLICATE_CODE') { - return this.conflict(res, result.error.message); - } - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data, 201); - } catch (error) { - next(error); - } - } - - /** - * PUT /branches/:id - Update branch - */ - async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - const { id } = req.params; - - const result = await branchService.update(tenantId, id, req.body, userId); - - if (!result.success) { - if (result.error.code === 'NOT_FOUND') { - return this.notFound(res, 'Branch'); - } - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data); - } catch (error) { - next(error); - } - } - - /** - * PATCH /branches/:id/status - Update branch status - */ - async updateStatus(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - const { id } = req.params; - const { status } = req.body; - - if (!Object.values(BranchStatus).includes(status)) { - return this.validationError(res, { status: 'Invalid status value' }); - } - - const result = await branchService.updateStatus(tenantId, id, status, userId); - - if (!result.success) { - if (result.error.code === 'NOT_FOUND') { - return this.notFound(res, 'Branch'); - } - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data); - } catch (error) { - next(error); - } - } - - /** - * DELETE /branches/:id - Delete branch - */ - async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { id } = req.params; - - const result = await branchService.delete(tenantId, id); - - if (!result.success) { - if (result.error.code === 'NOT_FOUND') { - return this.notFound(res, 'Branch'); - } - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, { deleted: true }); - } catch (error) { - next(error); - } - } - - /** - * GET /branches/:id/stats - Get branch statistics - */ - async getStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { id } = req.params; - - const exists = await branchService.exists(tenantId, id); - if (!exists) { - return this.notFound(res, 'Branch'); - } - - const stats = await branchService.getBranchStats(tenantId, id); - return this.success(res, stats); - } catch (error) { - next(error); - } - } - - /** - * GET /branches/nearby - Find nearby branches - */ - async findNearby(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { lat, lng, radius } = req.query; - - if (!lat || !lng) { - return this.validationError(res, { lat: 'Required', lng: 'Required' }); - } - - const branches = await branchService.findNearby( - tenantId, - parseFloat(lat as string), - parseFloat(lng as string), - radius ? parseFloat(radius as string) : 10 - ); - - return this.success(res, branches); - } catch (error) { - next(error); - } - } -} - -export const branchController = new BranchController(); diff --git a/backend/src/modules/branches/entities/branch-user.entity.ts b/backend/src/modules/branches/entities/branch-user.entity.ts deleted file mode 100644 index 69df3f9..0000000 --- a/backend/src/modules/branches/entities/branch-user.entity.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { Branch } from './branch.entity'; - -export enum BranchUserRole { - CASHIER = 'cashier', - SUPERVISOR = 'supervisor', - MANAGER = 'manager', - INVENTORY = 'inventory', - ADMIN = 'admin', -} - -@Entity('branch_users', { schema: 'retail' }) -@Index(['tenantId', 'branchId', 'userId'], { unique: true }) -@Index(['tenantId', 'userId']) -export class BranchUser { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - // Reference to erp-core user - @Column({ name: 'user_id', type: 'uuid' }) - userId: string; - - @Column({ - type: 'enum', - enum: BranchUserRole, - default: BranchUserRole.CASHIER, - }) - role: BranchUserRole; - - // PIN for quick login at POS - @Column({ name: 'pos_pin', length: 6, nullable: true }) - posPin: string; - - // Permissions (granular override of role) - @Column({ type: 'jsonb', nullable: true }) - permissions: { - canOpenSession?: boolean; - canCloseSession?: boolean; - canVoidSale?: boolean; - canApplyDiscount?: boolean; - maxDiscountPercent?: number; - canRefund?: boolean; - canViewReports?: boolean; - canManageInventory?: boolean; - canManagePrices?: boolean; - canManageUsers?: boolean; - }; - - // Commission settings - @Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 4, nullable: true }) - commissionRate: number; - - @Column({ name: 'commission_type', length: 20, nullable: true }) - commissionType: 'percentage' | 'fixed_per_sale' | 'fixed_per_item'; - - // Work schedule - @Column({ name: 'work_schedule', type: 'jsonb', nullable: true }) - workSchedule: { - monday?: { start: string; end: string }; - tuesday?: { start: string; end: string }; - wednesday?: { start: string; end: string }; - thursday?: { start: string; end: string }; - friday?: { start: string; end: string }; - saturday?: { start: string; end: string }; - sunday?: { start: string; end: string }; - }; - - // Default register assignment - @Column({ name: 'default_register_id', type: 'uuid', nullable: true }) - defaultRegisterId: string; - - // Activity tracking - @Column({ name: 'last_login_at', type: 'timestamp with time zone', nullable: true }) - lastLoginAt: Date; - - @Column({ name: 'last_activity_at', type: 'timestamp with time zone', nullable: true }) - lastActivityAt: Date; - - // Status - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive: boolean; - - @Column({ name: 'is_primary_branch', type: 'boolean', default: false }) - isPrimaryBranch: boolean; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy: string; - - // Relations - @ManyToOne(() => Branch, (branch) => branch.branchUsers) - @JoinColumn({ name: 'branch_id' }) - branch: Branch; -} diff --git a/backend/src/modules/branches/entities/branch.entity.ts b/backend/src/modules/branches/entities/branch.entity.ts deleted file mode 100644 index bb7096d..0000000 --- a/backend/src/modules/branches/entities/branch.entity.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { CashRegister } from './cash-register.entity'; -import { BranchUser } from './branch-user.entity'; - -export enum BranchType { - STORE = 'store', - WAREHOUSE = 'warehouse', - HYBRID = 'hybrid', -} - -export enum BranchStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', - TEMPORARILY_CLOSED = 'temporarily_closed', -} - -@Entity('branches', { schema: 'retail' }) -@Index(['tenantId', 'code'], { unique: true }) -@Index(['tenantId', 'status']) -export class Branch { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ length: 20, unique: true }) - code: string; - - @Column({ length: 100 }) - name: string; - - @Column({ - type: 'enum', - enum: BranchType, - default: BranchType.STORE, - }) - type: BranchType; - - @Column({ - type: 'enum', - enum: BranchStatus, - default: BranchStatus.ACTIVE, - }) - status: BranchStatus; - - // Address - @Column({ length: 255, nullable: true }) - address: string; - - @Column({ length: 100, nullable: true }) - city: string; - - @Column({ length: 100, nullable: true }) - state: string; - - @Column({ name: 'postal_code', length: 10, nullable: true }) - postalCode: string; - - @Column({ name: 'country_code', length: 3, default: 'MEX' }) - countryCode: string; - - // Contact - @Column({ length: 20, nullable: true }) - phone: string; - - @Column({ length: 100, nullable: true }) - email: string; - - // Manager reference (from erp-core users) - @Column({ name: 'manager_id', type: 'uuid', nullable: true }) - managerId: string; - - // Inventory reference (from erp-core) - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId: string; - - // Operating hours (JSON) - @Column({ name: 'operating_hours', type: 'jsonb', nullable: true }) - operatingHours: { - monday?: { open: string; close: string }; - tuesday?: { open: string; close: string }; - wednesday?: { open: string; close: string }; - thursday?: { open: string; close: string }; - friday?: { open: string; close: string }; - saturday?: { open: string; close: string }; - sunday?: { open: string; close: string }; - }; - - // POS Configuration - @Column({ name: 'default_price_list_id', type: 'uuid', nullable: true }) - defaultPriceListId: string; - - @Column({ name: 'allow_negative_stock', type: 'boolean', default: false }) - allowNegativeStock: boolean; - - @Column({ name: 'require_customer_for_sale', type: 'boolean', default: false }) - requireCustomerForSale: boolean; - - @Column({ name: 'auto_print_receipt', type: 'boolean', default: true }) - autoPrintReceipt: boolean; - - // Fiscal - @Column({ name: 'fiscal_address', type: 'text', nullable: true }) - fiscalAddress: string; - - @Column({ name: 'cfdi_config_id', type: 'uuid', nullable: true }) - cfdiConfigId: string; - - // Geolocation - @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true }) - latitude: number; - - @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true }) - longitude: number; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy: string; - - // Relations - @OneToMany(() => CashRegister, (register) => register.branch) - cashRegisters: CashRegister[]; - - @OneToMany(() => BranchUser, (bu) => bu.branch) - branchUsers: BranchUser[]; -} diff --git a/backend/src/modules/branches/entities/cash-register.entity.ts b/backend/src/modules/branches/entities/cash-register.entity.ts deleted file mode 100644 index faf8134..0000000 --- a/backend/src/modules/branches/entities/cash-register.entity.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { Branch } from './branch.entity'; - -export enum RegisterStatus { - AVAILABLE = 'available', - IN_USE = 'in_use', - CLOSED = 'closed', - MAINTENANCE = 'maintenance', -} - -export enum RegisterType { - STANDARD = 'standard', - EXPRESS = 'express', - SELF_SERVICE = 'self_service', -} - -@Entity('cash_registers', { schema: 'retail' }) -@Index(['tenantId', 'branchId', 'code'], { unique: true }) -@Index(['tenantId', 'status']) -export class CashRegister { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - @Column({ length: 20 }) - code: string; - - @Column({ length: 100 }) - name: string; - - @Column({ - type: 'enum', - enum: RegisterType, - default: RegisterType.STANDARD, - }) - type: RegisterType; - - @Column({ - type: 'enum', - enum: RegisterStatus, - default: RegisterStatus.AVAILABLE, - }) - status: RegisterStatus; - - // Hardware configuration - @Column({ name: 'printer_config', type: 'jsonb', nullable: true }) - printerConfig: { - type: 'thermal' | 'inkjet' | 'none'; - connectionType: 'usb' | 'network' | 'bluetooth'; - address?: string; - port?: number; - paperWidth: 58 | 80; - }; - - @Column({ name: 'scanner_config', type: 'jsonb', nullable: true }) - scannerConfig: { - enabled: boolean; - type: 'usb' | 'bluetooth'; - }; - - @Column({ name: 'cash_drawer_config', type: 'jsonb', nullable: true }) - cashDrawerConfig: { - enabled: boolean; - openCommand: string; - }; - - @Column({ name: 'display_config', type: 'jsonb', nullable: true }) - displayConfig: { - enabled: boolean; - type: 'lcd' | 'pole' | 'none'; - }; - - // Payment methods allowed - @Column({ name: 'allowed_payment_methods', type: 'jsonb', default: '["cash", "card", "transfer"]' }) - allowedPaymentMethods: string[]; - - // Current session info (denormalized for quick access) - @Column({ name: 'current_session_id', type: 'uuid', nullable: true }) - currentSessionId: string; - - @Column({ name: 'current_user_id', type: 'uuid', nullable: true }) - currentUserId: string; - - // Cash limits - @Column({ name: 'initial_cash_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - initialCashAmount: number; - - @Column({ name: 'max_cash_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - maxCashAmount: number; - - // Settings - @Column({ name: 'require_closing_count', type: 'boolean', default: true }) - requireClosingCount: boolean; - - @Column({ name: 'allow_blind_close', type: 'boolean', default: false }) - allowBlindClose: boolean; - - @Column({ name: 'auto_logout_minutes', type: 'int', default: 30 }) - autoLogoutMinutes: number; - - // Device identifier for offline sync - @Column({ name: 'device_uuid', type: 'uuid', nullable: true }) - deviceUuid: string; - - @Column({ name: 'last_sync_at', type: 'timestamp with time zone', nullable: true }) - lastSyncAt: Date; - - // Active flag - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive: boolean; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy: string; - - // Relations - @ManyToOne(() => Branch, (branch) => branch.cashRegisters) - @JoinColumn({ name: 'branch_id' }) - branch: Branch; -} diff --git a/backend/src/modules/branches/entities/index.ts b/backend/src/modules/branches/entities/index.ts deleted file mode 100644 index 8a15505..0000000 --- a/backend/src/modules/branches/entities/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './branch.entity'; -export * from './cash-register.entity'; -export * from './branch-user.entity'; diff --git a/backend/src/modules/branches/index.ts b/backend/src/modules/branches/index.ts deleted file mode 100644 index be64034..0000000 --- a/backend/src/modules/branches/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './entities'; -export * from './services'; -export { default as branchRoutes } from './routes/branch.routes'; diff --git a/backend/src/modules/branches/routes/branch.routes.ts b/backend/src/modules/branches/routes/branch.routes.ts deleted file mode 100644 index 0128cd7..0000000 --- a/backend/src/modules/branches/routes/branch.routes.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Router } from 'express'; -import { branchController } from '../controllers/branch.controller'; -import { authMiddleware, requireRoles } from '../../../shared/middleware/auth.middleware'; -import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware'; -import { AuthenticatedRequest } from '../../../shared/types'; - -const router = Router(); - -// All routes require tenant and authentication -router.use(tenantMiddleware); -router.use(authMiddleware); - -// List routes -router.get('/', (req, res, next) => branchController.list(req as AuthenticatedRequest, res, next)); -router.get('/active', (req, res, next) => branchController.listActive(req as AuthenticatedRequest, res, next)); -router.get('/nearby', (req, res, next) => branchController.findNearby(req as AuthenticatedRequest, res, next)); - -// Single branch routes -router.get('/code/:code', (req, res, next) => branchController.getByCode(req as AuthenticatedRequest, res, next)); -router.get('/:id', (req, res, next) => branchController.getById(req as AuthenticatedRequest, res, next)); -router.get('/:id/stats', (req, res, next) => branchController.getStats(req as AuthenticatedRequest, res, next)); - -// Admin routes (require manager or admin role) -router.post( - '/', - requireRoles('admin', 'manager'), - (req, res, next) => branchController.create(req as AuthenticatedRequest, res, next) -); - -router.put( - '/:id', - requireRoles('admin', 'manager'), - (req, res, next) => branchController.update(req as AuthenticatedRequest, res, next) -); - -router.patch( - '/:id/status', - requireRoles('admin', 'manager'), - (req, res, next) => branchController.updateStatus(req as AuthenticatedRequest, res, next) -); - -router.delete( - '/:id', - requireRoles('admin'), - (req, res, next) => branchController.delete(req as AuthenticatedRequest, res, next) -); - -export default router; diff --git a/backend/src/modules/branches/services/branch.service.ts b/backend/src/modules/branches/services/branch.service.ts deleted file mode 100644 index b5652b3..0000000 --- a/backend/src/modules/branches/services/branch.service.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { Repository, DeepPartial } from 'typeorm'; -import { AppDataSource } from '../../../config/typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult, QueryOptions } from '../../../shared/types'; -import { Branch, BranchStatus, BranchType } from '../entities/branch.entity'; -import { CashRegister } from '../entities/cash-register.entity'; - -export class BranchService extends BaseService { - private cashRegisterRepository: Repository; - - constructor() { - super(AppDataSource.getRepository(Branch)); - this.cashRegisterRepository = AppDataSource.getRepository(CashRegister); - } - - /** - * Find all active branches for a tenant - */ - async findActiveBranches(tenantId: string): Promise { - return this.repository.find({ - where: { tenantId, status: BranchStatus.ACTIVE }, - order: { name: 'ASC' }, - }); - } - - /** - * Find branch by code - */ - async findByCode(tenantId: string, code: string): Promise { - return this.repository.findOne({ - where: { tenantId, code }, - }); - } - - /** - * Find branches by type - */ - async findByType(tenantId: string, type: BranchType): Promise { - return this.repository.find({ - where: { tenantId, type }, - order: { name: 'ASC' }, - }); - } - - /** - * Create branch with default cash register - */ - async createWithRegister( - tenantId: string, - data: DeepPartial, - userId?: string - ): Promise> { - const queryRunner = AppDataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - // Check if code already exists - const existing = await this.findByCode(tenantId, data.code as string); - if (existing) { - return { - success: false, - error: { - code: 'DUPLICATE_CODE', - message: `Branch with code '${data.code}' already exists`, - }, - }; - } - - // Create branch - const branch = this.repository.create({ - ...data, - tenantId, - createdBy: userId, - updatedBy: userId, - }); - const savedBranch = await queryRunner.manager.save(branch); - - // Create default cash register - const register = this.cashRegisterRepository.create({ - tenantId, - branchId: savedBranch.id, - code: `${savedBranch.code}-01`, - name: `Caja Principal - ${savedBranch.name}`, - createdBy: userId, - updatedBy: userId, - }); - await queryRunner.manager.save(register); - - await queryRunner.commitTransaction(); - - return { success: true, data: savedBranch }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_FAILED', - message: error.message || 'Failed to create branch', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Update branch status - */ - async updateStatus( - tenantId: string, - branchId: string, - status: BranchStatus, - userId?: string - ): Promise> { - const branch = await this.findById(tenantId, branchId); - if (!branch) { - return { - success: false, - error: { - code: 'NOT_FOUND', - message: 'Branch not found', - }, - }; - } - - // If closing branch, check for open sessions - if (status === BranchStatus.INACTIVE) { - const openSessions = await this.hasOpenSessions(tenantId, branchId); - if (openSessions) { - return { - success: false, - error: { - code: 'OPEN_SESSIONS', - message: 'Cannot deactivate branch with open POS sessions', - }, - }; - } - } - - return this.update(tenantId, branchId, { status }, userId); - } - - /** - * Check if branch has open POS sessions - */ - private async hasOpenSessions(tenantId: string, branchId: string): Promise { - const count = await AppDataSource.getRepository('pos_sessions').count({ - where: { - tenantId, - branchId, - status: 'open', - }, - }); - return count > 0; - } - - /** - * Get branch with cash registers - */ - async getBranchWithRegisters( - tenantId: string, - branchId: string - ): Promise { - return this.repository.findOne({ - where: { tenantId, id: branchId }, - relations: ['cashRegisters'], - }); - } - - /** - * Get branch statistics - */ - async getBranchStats(tenantId: string, branchId: string): Promise<{ - totalRegisters: number; - activeRegisters: number; - todaySales: number; - openSessions: number; - }> { - const registers = await this.cashRegisterRepository.count({ - where: { tenantId, branchId }, - }); - - const activeRegisters = await this.cashRegisterRepository.count({ - where: { tenantId, branchId, isActive: true }, - }); - - // TODO: Add sales and sessions queries when those services are implemented - - return { - totalRegisters: registers, - activeRegisters, - todaySales: 0, - openSessions: 0, - }; - } - - /** - * Find nearby branches by coordinates - */ - async findNearby( - tenantId: string, - latitude: number, - longitude: number, - radiusKm: number = 10 - ): Promise { - // Using Haversine formula in SQL for distance calculation - const branches = await this.repository - .createQueryBuilder('branch') - .where('branch.tenantId = :tenantId', { tenantId }) - .andWhere('branch.status = :status', { status: BranchStatus.ACTIVE }) - .andWhere('branch.latitude IS NOT NULL') - .andWhere('branch.longitude IS NOT NULL') - .andWhere( - `( - 6371 * acos( - cos(radians(:lat)) * cos(radians(branch.latitude)) * - cos(radians(branch.longitude) - radians(:lng)) + - sin(radians(:lat)) * sin(radians(branch.latitude)) - ) - ) <= :radius`, - { lat: latitude, lng: longitude, radius: radiusKm } - ) - .orderBy( - `( - 6371 * acos( - cos(radians(:lat)) * cos(radians(branch.latitude)) * - cos(radians(branch.longitude) - radians(:lng)) + - sin(radians(:lat)) * sin(radians(branch.latitude)) - ) - )`, - 'ASC' - ) - .setParameters({ lat: latitude, lng: longitude }) - .getMany(); - - return branches; - } -} - -// Export singleton instance -export const branchService = new BranchService(); diff --git a/backend/src/modules/branches/services/index.ts b/backend/src/modules/branches/services/index.ts deleted file mode 100644 index ef1d184..0000000 --- a/backend/src/modules/branches/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './branch.service'; diff --git a/backend/src/modules/branches/validation/branch.schema.ts b/backend/src/modules/branches/validation/branch.schema.ts deleted file mode 100644 index cedad78..0000000 --- a/backend/src/modules/branches/validation/branch.schema.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { z } from 'zod'; -import { - uuidSchema, - emailSchema, - phoneSchema, - postalCodeSchema, - operatingHoursSchema, - coordinatesSchema, - paginationSchema, -} from '../../../shared/validation/common.schema'; - -// Enums -export const branchTypeEnum = z.enum(['store', 'warehouse', 'hybrid']); -export const branchStatusEnum = z.enum(['active', 'inactive', 'temporarily_closed']); - -// Create branch schema -export const createBranchSchema = z.object({ - code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'), - name: z.string().min(1).max(100), - type: branchTypeEnum.default('store'), - status: branchStatusEnum.default('active'), - - // Address - address: z.string().max(255).optional(), - city: z.string().max(100).optional(), - state: z.string().max(100).optional(), - postalCode: postalCodeSchema.optional(), - countryCode: z.string().length(3).default('MEX'), - - // Contact - phone: phoneSchema.optional(), - email: emailSchema.optional(), - - // References - managerId: uuidSchema.optional(), - warehouseId: uuidSchema.optional(), - - // Configuration - operatingHours: operatingHoursSchema, - defaultPriceListId: uuidSchema.optional(), - allowNegativeStock: z.boolean().default(false), - requireCustomerForSale: z.boolean().default(false), - autoPrintReceipt: z.boolean().default(true), - - // Fiscal - fiscalAddress: z.string().optional(), - cfdiConfigId: uuidSchema.optional(), - - // Geolocation - latitude: z.coerce.number().min(-90).max(90).optional(), - longitude: z.coerce.number().min(-180).max(180).optional(), - - // Metadata - metadata: z.record(z.any()).optional(), -}); - -// Update branch schema (all fields optional) -export const updateBranchSchema = createBranchSchema.partial().omit({ code: true }); - -// Update status schema -export const updateBranchStatusSchema = z.object({ - status: branchStatusEnum, -}); - -// List branches query schema -export const listBranchesQuerySchema = paginationSchema.extend({ - status: branchStatusEnum.optional(), - type: branchTypeEnum.optional(), - search: z.string().optional(), -}); - -// Find nearby query schema -export const findNearbyQuerySchema = z.object({ - lat: z.coerce.number().min(-90).max(90), - lng: z.coerce.number().min(-180).max(180), - radius: z.coerce.number().min(0.1).max(100).default(10), -}); - -// Types -export type CreateBranchInput = z.infer; -export type UpdateBranchInput = z.infer; -export type ListBranchesQuery = z.infer; diff --git a/backend/src/modules/cash/controllers/cash.controller.ts b/backend/src/modules/cash/controllers/cash.controller.ts deleted file mode 100644 index eee370c..0000000 --- a/backend/src/modules/cash/controllers/cash.controller.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { Request, Response } from 'express'; -import { BaseController } from '../../../shared/controllers/base.controller'; -import { CashMovementService, MovementQueryOptions } from '../services/cash-movement.service'; -import { CashClosingService, ClosingQueryOptions } from '../services/cash-closing.service'; - -export class CashController extends BaseController { - constructor( - private readonly movementService: CashMovementService, - private readonly closingService: CashClosingService - ) { - super(); - } - - // ==================== MOVEMENT ENDPOINTS ==================== - - /** - * Create a cash movement - * POST /cash/movements - */ - createMovement = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - - const result = await this.movementService.createMovement(tenantId, req.body, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.created(res, result.data); - }; - - /** - * List movements - * GET /cash/movements - */ - listMovements = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const options: MovementQueryOptions = { - page: Number(req.query.page) || 1, - limit: Number(req.query.limit) || 20, - branchId: req.query.branchId as string, - sessionId: req.query.sessionId as string, - registerId: req.query.registerId as string, - type: req.query.type as any, - reason: req.query.reason as any, - status: req.query.status as any, - startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, - endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, - }; - - const result = await this.movementService.findMovements(tenantId, options); - this.paginated(res, result.data, result.total, options.page!, options.limit!); - }; - - /** - * Get movement by ID - * GET /cash/movements/:id - */ - getMovement = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const { id } = req.params; - - const movement = await this.movementService.findById(tenantId, id); - - if (!movement) { - this.notFound(res, 'Movement'); - return; - } - - this.success(res, movement); - }; - - /** - * Approve movement - * POST /cash/movements/:id/approve - */ - approveMovement = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - - const result = await this.movementService.approveMovement(tenantId, id, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Movement approved'); - }; - - /** - * Reject movement - * POST /cash/movements/:id/reject - */ - rejectMovement = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { reason } = req.body; - - const result = await this.movementService.rejectMovement(tenantId, id, userId, reason); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Movement rejected'); - }; - - /** - * Cancel movement - * POST /cash/movements/:id/cancel - */ - cancelMovement = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { reason } = req.body; - - const result = await this.movementService.cancelMovement(tenantId, id, userId, reason); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Movement cancelled'); - }; - - /** - * Get session summary - * GET /cash/sessions/:sessionId/summary - */ - getSessionSummary = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const { sessionId } = req.params; - - const summary = await this.movementService.getSessionSummary(tenantId, sessionId); - this.success(res, summary); - }; - - // ==================== CLOSING ENDPOINTS ==================== - - /** - * Create a cash closing - * POST /cash/closings - */ - createClosing = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - - const result = await this.closingService.createClosing(tenantId, req.body, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.created(res, result.data); - }; - - /** - * List closings - * GET /cash/closings - */ - listClosings = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const options: ClosingQueryOptions = { - page: Number(req.query.page) || 1, - limit: Number(req.query.limit) || 20, - branchId: req.query.branchId as string, - sessionId: req.query.sessionId as string, - status: req.query.status as any, - type: req.query.type as any, - startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, - endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, - }; - - const result = await this.closingService.findClosings(tenantId, options); - this.paginated(res, result.data, result.total, options.page!, options.limit!); - }; - - /** - * Get closing by ID - * GET /cash/closings/:id - */ - getClosing = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const { id } = req.params; - - const closing = await this.closingService.getClosingWithCounts(tenantId, id); - - if (!closing) { - this.notFound(res, 'Closing'); - return; - } - - this.success(res, closing); - }; - - /** - * Submit cash count - * POST /cash/closings/:id/count - */ - submitCashCount = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { denominations } = req.body; - - const result = await this.closingService.submitCashCount(tenantId, id, denominations, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Cash count submitted'); - }; - - /** - * Submit payment counts - * POST /cash/closings/:id/payments - */ - submitPaymentCounts = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - - const result = await this.closingService.submitPaymentCounts(tenantId, id, req.body, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Payment counts submitted'); - }; - - /** - * Approve closing - * POST /cash/closings/:id/approve - */ - approveClosing = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { notes } = req.body; - - const result = await this.closingService.approveClosing(tenantId, id, userId, notes); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Closing approved'); - }; - - /** - * Reject closing - * POST /cash/closings/:id/reject - */ - rejectClosing = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { notes } = req.body; - - const result = await this.closingService.rejectClosing(tenantId, id, userId, notes); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Closing rejected'); - }; - - /** - * Reconcile closing - * POST /cash/closings/:id/reconcile - */ - reconcileClosing = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { depositAmount, depositReference, depositDate } = req.body; - - const result = await this.closingService.reconcileClosing( - tenantId, - id, - { - amount: depositAmount, - reference: depositReference, - date: new Date(depositDate), - }, - userId - ); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Closing reconciled'); - }; - - /** - * Get daily summary - * GET /cash/summary/daily - */ - getDailySummary = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const branchId = req.branchContext?.branchId || (req.query.branchId as string); - const date = req.query.date ? new Date(req.query.date as string) : new Date(); - - if (!branchId) { - this.error(res, 'Branch ID is required', 400); - return; - } - - const summary = await this.closingService.getDailySummary(tenantId, branchId, date); - this.success(res, summary); - }; -} diff --git a/backend/src/modules/cash/entities/cash-closing.entity.ts b/backend/src/modules/cash/entities/cash-closing.entity.ts deleted file mode 100644 index 48899fe..0000000 --- a/backend/src/modules/cash/entities/cash-closing.entity.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { CashCount } from './cash-count.entity'; - -export enum ClosingStatus { - IN_PROGRESS = 'in_progress', - PENDING_REVIEW = 'pending_review', - APPROVED = 'approved', - REJECTED = 'rejected', - RECONCILED = 'reconciled', -} - -export enum ClosingType { - SHIFT = 'shift', - DAILY = 'daily', - WEEKLY = 'weekly', - MONTHLY = 'monthly', -} - -@Entity('cash_closings', { schema: 'retail' }) -@Index(['tenantId', 'branchId', 'closingDate']) -@Index(['tenantId', 'status']) -@Index(['tenantId', 'sessionId']) -export class CashClosing { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - @Column({ name: 'register_id', type: 'uuid', nullable: true }) - registerId: string; - - @Column({ name: 'session_id', type: 'uuid', nullable: true }) - sessionId: string; - - @Column({ length: 30, unique: true }) - number: string; - - @Column({ - type: 'enum', - enum: ClosingType, - default: ClosingType.SHIFT, - }) - type: ClosingType; - - @Column({ - type: 'enum', - enum: ClosingStatus, - default: ClosingStatus.IN_PROGRESS, - }) - status: ClosingStatus; - - @Column({ name: 'closing_date', type: 'date' }) - closingDate: Date; - - @Column({ name: 'period_start', type: 'timestamp with time zone' }) - periodStart: Date; - - @Column({ name: 'period_end', type: 'timestamp with time zone' }) - periodEnd: Date; - - // Opening balance - @Column({ name: 'opening_balance', type: 'decimal', precision: 15, scale: 2, default: 0 }) - openingBalance: number; - - // Expected amounts by payment method - @Column({ name: 'expected_cash', type: 'decimal', precision: 15, scale: 2, default: 0 }) - expectedCash: number; - - @Column({ name: 'expected_card', type: 'decimal', precision: 15, scale: 2, default: 0 }) - expectedCard: number; - - @Column({ name: 'expected_transfer', type: 'decimal', precision: 15, scale: 2, default: 0 }) - expectedTransfer: number; - - @Column({ name: 'expected_other', type: 'decimal', precision: 15, scale: 2, default: 0 }) - expectedOther: number; - - @Column({ name: 'expected_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) - expectedTotal: number; - - // Counted amounts - @Column({ name: 'counted_cash', type: 'decimal', precision: 15, scale: 2, nullable: true }) - countedCash: number; - - @Column({ name: 'counted_card', type: 'decimal', precision: 15, scale: 2, nullable: true }) - countedCard: number; - - @Column({ name: 'counted_transfer', type: 'decimal', precision: 15, scale: 2, nullable: true }) - countedTransfer: number; - - @Column({ name: 'counted_other', type: 'decimal', precision: 15, scale: 2, nullable: true }) - countedOther: number; - - @Column({ name: 'counted_total', type: 'decimal', precision: 15, scale: 2, nullable: true }) - countedTotal: number; - - // Differences - @Column({ name: 'cash_difference', type: 'decimal', precision: 15, scale: 2, nullable: true }) - cashDifference: number; - - @Column({ name: 'card_difference', type: 'decimal', precision: 15, scale: 2, nullable: true }) - cardDifference: number; - - @Column({ name: 'total_difference', type: 'decimal', precision: 15, scale: 2, nullable: true }) - totalDifference: number; - - // Transaction summary - @Column({ name: 'total_sales', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalSales: number; - - @Column({ name: 'total_refunds', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalRefunds: number; - - @Column({ name: 'total_discounts', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalDiscounts: number; - - @Column({ name: 'net_sales', type: 'decimal', precision: 15, scale: 2, default: 0 }) - netSales: number; - - @Column({ name: 'orders_count', type: 'int', default: 0 }) - ordersCount: number; - - @Column({ name: 'refunds_count', type: 'int', default: 0 }) - refundsCount: number; - - @Column({ name: 'voided_count', type: 'int', default: 0 }) - voidedCount: number; - - // Cash movements - @Column({ name: 'cash_in_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) - cashInTotal: number; - - @Column({ name: 'cash_out_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) - cashOutTotal: number; - - // Deposit info - @Column({ name: 'deposit_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - depositAmount: number; - - @Column({ name: 'deposit_reference', length: 100, nullable: true }) - depositReference: string; - - @Column({ name: 'deposit_date', type: 'date', nullable: true }) - depositDate: Date; - - // User info - @Column({ name: 'closed_by', type: 'uuid' }) - closedBy: string; - - @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) - reviewedBy: string; - - @Column({ name: 'reviewed_at', type: 'timestamp with time zone', nullable: true }) - reviewedAt: Date; - - @Column({ name: 'approved_by', type: 'uuid', nullable: true }) - approvedBy: string; - - @Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true }) - approvedAt: Date; - - // Notes - @Column({ name: 'closing_notes', type: 'text', nullable: true }) - closingNotes: string; - - @Column({ name: 'review_notes', type: 'text', nullable: true }) - reviewNotes: string; - - // Detailed breakdown by payment method - @Column({ name: 'payment_breakdown', type: 'jsonb', nullable: true }) - paymentBreakdown: { - method: string; - expected: number; - counted: number; - difference: number; - transactionCount: number; - }[]; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - // Relations - @OneToMany(() => CashCount, (count) => count.closing) - cashCounts: CashCount[]; -} diff --git a/backend/src/modules/cash/entities/cash-count.entity.ts b/backend/src/modules/cash/entities/cash-count.entity.ts deleted file mode 100644 index fca51ab..0000000 --- a/backend/src/modules/cash/entities/cash-count.entity.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { CashClosing } from './cash-closing.entity'; - -export enum DenominationType { - BILL = 'bill', - COIN = 'coin', -} - -@Entity('cash_counts', { schema: 'retail' }) -@Index(['tenantId', 'closingId']) -@Index(['tenantId', 'sessionId']) -export class CashCount { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'closing_id', type: 'uuid', nullable: true }) - closingId: string; - - @Column({ name: 'session_id', type: 'uuid', nullable: true }) - sessionId: string; - - @Column({ name: 'register_id', type: 'uuid' }) - registerId: string; - - @Column({ - type: 'enum', - enum: DenominationType, - }) - type: DenominationType; - - // Denomination value (e.g., 500, 200, 100, 50, 20, 10, 5, 2, 1, 0.50, 0.20, 0.10) - @Column({ type: 'decimal', precision: 10, scale: 2 }) - denomination: number; - - // Count - @Column({ type: 'int', default: 0 }) - quantity: number; - - // Calculated total (denomination * quantity) - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - // Currency - @Column({ name: 'currency_code', length: 3, default: 'MXN' }) - currencyCode: string; - - // Count context - @Column({ name: 'count_type', length: 20, default: 'closing' }) - countType: 'opening' | 'closing' | 'audit' | 'transfer'; - - // User who counted - @Column({ name: 'counted_by', type: 'uuid' }) - countedBy: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - // Relations - @ManyToOne(() => CashClosing, (closing) => closing.cashCounts) - @JoinColumn({ name: 'closing_id' }) - closing: CashClosing; -} diff --git a/backend/src/modules/cash/entities/cash-movement.entity.ts b/backend/src/modules/cash/entities/cash-movement.entity.ts deleted file mode 100644 index c6c30ef..0000000 --- a/backend/src/modules/cash/entities/cash-movement.entity.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - Index, -} from 'typeorm'; - -export enum MovementType { - CASH_IN = 'cash_in', - CASH_OUT = 'cash_out', - DEPOSIT = 'deposit', - WITHDRAWAL = 'withdrawal', - ADJUSTMENT = 'adjustment', - OPENING = 'opening', - CLOSING = 'closing', -} - -export enum MovementReason { - CHANGE_FUND = 'change_fund', - PETTY_CASH = 'petty_cash', - BANK_DEPOSIT = 'bank_deposit', - SUPPLIER_PAYMENT = 'supplier_payment', - EXPENSE = 'expense', - SALARY_ADVANCE = 'salary_advance', - CORRECTION = 'correction', - OTHER = 'other', -} - -export enum MovementStatus { - PENDING = 'pending', - APPROVED = 'approved', - REJECTED = 'rejected', - CANCELLED = 'cancelled', -} - -@Entity('cash_movements', { schema: 'retail' }) -@Index(['tenantId', 'branchId', 'createdAt']) -@Index(['tenantId', 'sessionId']) -@Index(['tenantId', 'type', 'status']) -export class CashMovement { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - @Column({ name: 'register_id', type: 'uuid' }) - registerId: string; - - @Column({ name: 'session_id', type: 'uuid' }) - sessionId: string; - - @Column({ length: 30, unique: true }) - number: string; - - @Column({ - type: 'enum', - enum: MovementType, - }) - type: MovementType; - - @Column({ - type: 'enum', - enum: MovementReason, - default: MovementReason.OTHER, - }) - reason: MovementReason; - - @Column({ - type: 'enum', - enum: MovementStatus, - default: MovementStatus.PENDING, - }) - status: MovementStatus; - - // Amount (positive for in, negative for out stored as absolute) - @Column({ type: 'decimal', precision: 15, scale: 2 }) - amount: number; - - // Running balance after movement - @Column({ name: 'balance_after', type: 'decimal', precision: 15, scale: 2, nullable: true }) - balanceAfter: number; - - // Description - @Column({ type: 'text' }) - description: string; - - // Reference (external document, invoice, etc.) - @Column({ name: 'reference_type', length: 50, nullable: true }) - referenceType: string; - - @Column({ name: 'reference_id', type: 'uuid', nullable: true }) - referenceId: string; - - @Column({ name: 'reference_number', length: 50, nullable: true }) - referenceNumber: string; - - // Authorization - @Column({ name: 'requires_approval', type: 'boolean', default: false }) - requiresApproval: boolean; - - @Column({ name: 'approved_by', type: 'uuid', nullable: true }) - approvedBy: string; - - @Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true }) - approvedAt: Date; - - @Column({ name: 'rejection_reason', length: 255, nullable: true }) - rejectionReason: string; - - // Person who receives/gives cash (for expenses, advances, etc.) - @Column({ name: 'recipient_name', length: 200, nullable: true }) - recipientName: string; - - @Column({ name: 'recipient_id', type: 'uuid', nullable: true }) - recipientId: string; - - // Supporting documents - @Column({ name: 'attachment_ids', type: 'jsonb', nullable: true }) - attachmentIds: string[]; - - // Financial account (for accounting integration) - @Column({ name: 'account_id', type: 'uuid', nullable: true }) - accountId: string; - - // Bank deposit info - @Column({ name: 'bank_account', length: 50, nullable: true }) - bankAccount: string; - - @Column({ name: 'deposit_slip_number', length: 50, nullable: true }) - depositSlipNumber: string; - - // User who created the movement - @Column({ name: 'created_by', type: 'uuid' }) - createdBy: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} diff --git a/backend/src/modules/cash/entities/index.ts b/backend/src/modules/cash/entities/index.ts deleted file mode 100644 index d1b8e43..0000000 --- a/backend/src/modules/cash/entities/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './cash-movement.entity'; -export * from './cash-closing.entity'; -export * from './cash-count.entity'; diff --git a/backend/src/modules/cash/index.ts b/backend/src/modules/cash/index.ts deleted file mode 100644 index 43cf4c8..0000000 --- a/backend/src/modules/cash/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './entities'; -export * from './services'; -export * from './controllers/cash.controller'; -export * from './routes/cash.routes'; -export * from './validation/cash.schema'; diff --git a/backend/src/modules/cash/routes/cash.routes.ts b/backend/src/modules/cash/routes/cash.routes.ts deleted file mode 100644 index 0b8a67b..0000000 --- a/backend/src/modules/cash/routes/cash.routes.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Router } from 'express'; -import { CashController } from '../controllers/cash.controller'; -import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware'; -import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware'; -import { idParamSchema } from '../../../shared/validation/common.schema'; -import { - createMovementSchema, - movementActionSchema, - listMovementsQuerySchema, - createClosingSchema, - submitCashCountSchema, - submitPaymentCountsSchema, - closingActionSchema, - rejectClosingSchema, - reconcileClosingSchema, - listClosingsQuerySchema, -} from '../validation/cash.schema'; - -export function createCashRoutes(controller: CashController): Router { - const router = Router(); - - // Apply auth middleware to all routes - router.use(requireAuth); - - // ==================== MOVEMENT ROUTES ==================== - - // Create movement - router.post( - '/movements', - requirePermissions(['cash.movements.create']), - validateBody(createMovementSchema), - controller.createMovement - ); - - // List movements - router.get( - '/movements', - requirePermissions(['cash.movements.view']), - validateQuery(listMovementsQuerySchema), - controller.listMovements - ); - - // Get movement by ID - router.get( - '/movements/:id', - requirePermissions(['cash.movements.view']), - controller.getMovement - ); - - // Approve movement - router.post( - '/movements/:id/approve', - requireRoles(['admin', 'supervisor', 'manager']), - controller.approveMovement - ); - - // Reject movement - router.post( - '/movements/:id/reject', - requireRoles(['admin', 'supervisor', 'manager']), - validateBody(movementActionSchema), - controller.rejectMovement - ); - - // Cancel movement - router.post( - '/movements/:id/cancel', - requirePermissions(['cash.movements.cancel']), - validateBody(movementActionSchema), - controller.cancelMovement - ); - - // Get session summary - router.get( - '/sessions/:sessionId/summary', - requirePermissions(['cash.movements.view']), - controller.getSessionSummary - ); - - // ==================== CLOSING ROUTES ==================== - - // Create closing - router.post( - '/closings', - requirePermissions(['cash.closings.create']), - validateBody(createClosingSchema), - controller.createClosing - ); - - // List closings - router.get( - '/closings', - requirePermissions(['cash.closings.view']), - validateQuery(listClosingsQuerySchema), - controller.listClosings - ); - - // Get closing by ID - router.get( - '/closings/:id', - requirePermissions(['cash.closings.view']), - controller.getClosing - ); - - // Submit cash count - router.post( - '/closings/:id/count', - requirePermissions(['cash.closings.count']), - validateBody(submitCashCountSchema), - controller.submitCashCount - ); - - // Submit payment counts - router.post( - '/closings/:id/payments', - requirePermissions(['cash.closings.count']), - validateBody(submitPaymentCountsSchema), - controller.submitPaymentCounts - ); - - // Approve closing - router.post( - '/closings/:id/approve', - requireRoles(['admin', 'supervisor', 'manager']), - validateBody(closingActionSchema), - controller.approveClosing - ); - - // Reject closing - router.post( - '/closings/:id/reject', - requireRoles(['admin', 'supervisor', 'manager']), - validateBody(rejectClosingSchema), - controller.rejectClosing - ); - - // Reconcile closing - router.post( - '/closings/:id/reconcile', - requireRoles(['admin', 'accountant']), - validateBody(reconcileClosingSchema), - controller.reconcileClosing - ); - - // ==================== SUMMARY ROUTES ==================== - - // Get daily summary - router.get( - '/summary/daily', - requirePermissions(['cash.reports.view']), - controller.getDailySummary - ); - - return router; -} diff --git a/backend/src/modules/cash/services/cash-closing.service.ts b/backend/src/modules/cash/services/cash-closing.service.ts deleted file mode 100644 index df32521..0000000 --- a/backend/src/modules/cash/services/cash-closing.service.ts +++ /dev/null @@ -1,568 +0,0 @@ -import { Repository, DataSource, Between } from 'typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult, QueryOptions } from '../../../shared/types'; -import { - CashClosing, - ClosingStatus, - ClosingType, -} from '../entities/cash-closing.entity'; -import { CashCount, DenominationType } from '../entities/cash-count.entity'; -import { CashMovement, MovementStatus } from '../entities/cash-movement.entity'; - -export interface CreateClosingInput { - branchId: string; - registerId?: string; - sessionId?: string; - type: ClosingType; - periodStart: Date; - periodEnd: Date; - openingBalance: number; - closingNotes?: string; -} - -export interface ClosingQueryOptions extends QueryOptions { - branchId?: string; - sessionId?: string; - status?: ClosingStatus; - type?: ClosingType; - startDate?: Date; - endDate?: Date; -} - -export interface DenominationCount { - type: DenominationType; - denomination: number; - quantity: number; -} - -export interface PaymentTotals { - cash: number; - card: number; - transfer: number; - other: number; -} - -export class CashClosingService extends BaseService { - constructor( - repository: Repository, - private readonly countRepository: Repository, - private readonly movementRepository: Repository, - private readonly dataSource: DataSource - ) { - super(repository); - } - - /** - * Generate closing number - */ - private async generateClosingNumber( - tenantId: string, - branchId: string, - type: ClosingType - ): Promise { - const today = new Date(); - const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); - const typePrefix = type === ClosingType.SHIFT ? 'CLS' : type.toUpperCase().slice(0, 3); - - const count = await this.repository.count({ - where: { - tenantId, - branchId, - type, - createdAt: Between( - new Date(today.setHours(0, 0, 0, 0)), - new Date(today.setHours(23, 59, 59, 999)) - ), - }, - }); - - return `${typePrefix}-${datePrefix}-${String(count + 1).padStart(3, '0')}`; - } - - /** - * Calculate expected amounts from POS orders and movements - */ - private async calculateExpectedAmounts( - tenantId: string, - sessionId: string | null, - branchId: string, - periodStart: Date, - periodEnd: Date - ): Promise<{ - expected: PaymentTotals; - movements: { in: number; out: number }; - transactions: { sales: number; refunds: number; discounts: number; orders: number; refundsCount: number; voidedCount: number }; - }> { - // Get movements in period - const movements = await this.movementRepository.find({ - where: { - tenantId, - branchId, - status: MovementStatus.APPROVED, - ...(sessionId ? { sessionId } : {}), - }, - }); - - let cashIn = 0; - let cashOut = 0; - for (const m of movements) { - if (['cash_in', 'opening'].includes(m.type)) { - cashIn += Number(m.amount); - } else if (['cash_out', 'closing', 'deposit', 'withdrawal'].includes(m.type)) { - cashOut += Number(m.amount); - } - } - - // TODO: Query POS orders to calculate payment totals - // This would require the POSOrder and POSPayment repositories - // For now, return placeholder values that can be populated - - return { - expected: { - cash: cashIn - cashOut, - card: 0, - transfer: 0, - other: 0, - }, - movements: { - in: cashIn, - out: cashOut, - }, - transactions: { - sales: 0, - refunds: 0, - discounts: 0, - orders: 0, - refundsCount: 0, - voidedCount: 0, - }, - }; - } - - /** - * Create a new cash closing - */ - async createClosing( - tenantId: string, - input: CreateClosingInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const number = await this.generateClosingNumber(tenantId, input.branchId, input.type); - - // Calculate expected amounts - const { expected, movements, transactions } = await this.calculateExpectedAmounts( - tenantId, - input.sessionId ?? null, - input.branchId, - input.periodStart, - input.periodEnd - ); - - const closing = queryRunner.manager.create(CashClosing, { - tenantId, - branchId: input.branchId, - registerId: input.registerId, - sessionId: input.sessionId, - number, - type: input.type, - status: ClosingStatus.IN_PROGRESS, - closingDate: new Date(), - periodStart: input.periodStart, - periodEnd: input.periodEnd, - openingBalance: input.openingBalance, - expectedCash: expected.cash, - expectedCard: expected.card, - expectedTransfer: expected.transfer, - expectedOther: expected.other, - expectedTotal: expected.cash + expected.card + expected.transfer + expected.other, - totalSales: transactions.sales, - totalRefunds: transactions.refunds, - totalDiscounts: transactions.discounts, - netSales: transactions.sales - transactions.refunds - transactions.discounts, - ordersCount: transactions.orders, - refundsCount: transactions.refundsCount, - voidedCount: transactions.voidedCount, - cashInTotal: movements.in, - cashOutTotal: movements.out, - closedBy: userId, - closingNotes: input.closingNotes, - }); - - const savedClosing = await queryRunner.manager.save(closing); - await queryRunner.commitTransaction(); - - return { success: true, data: savedClosing }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_CLOSING_ERROR', - message: error instanceof Error ? error.message : 'Failed to create closing', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Submit cash count for a closing - */ - async submitCashCount( - tenantId: string, - closingId: string, - denominations: DenominationCount[], - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const closing = await queryRunner.manager.findOne(CashClosing, { - where: { id: closingId, tenantId }, - }); - - if (!closing) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Closing not found' }, - }; - } - - if (closing.status !== ClosingStatus.IN_PROGRESS) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Closing is not in progress' }, - }; - } - - // Delete existing counts - await queryRunner.manager.delete(CashCount, { closingId, tenantId }); - - // Create new counts - let totalCounted = 0; - for (const d of denominations) { - const total = d.denomination * d.quantity; - totalCounted += total; - - const count = queryRunner.manager.create(CashCount, { - tenantId, - closingId, - sessionId: closing.sessionId, - registerId: closing.registerId, - type: d.type, - denomination: d.denomination, - quantity: d.quantity, - total, - countType: 'closing', - countedBy: userId, - }); - - await queryRunner.manager.save(count); - } - - // Update closing with counted values - closing.countedCash = totalCounted; - closing.countedTotal = totalCounted + (closing.countedCard ?? 0) + - (closing.countedTransfer ?? 0) + (closing.countedOther ?? 0); - closing.cashDifference = totalCounted - Number(closing.expectedCash); - closing.totalDifference = Number(closing.countedTotal) - Number(closing.expectedTotal); - closing.status = ClosingStatus.PENDING_REVIEW; - - await queryRunner.manager.save(closing); - await queryRunner.commitTransaction(); - - return { success: true, data: closing }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'SUBMIT_COUNT_ERROR', - message: error instanceof Error ? error.message : 'Failed to submit cash count', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Submit other payment method counts (card, transfer, etc.) - */ - async submitPaymentCounts( - tenantId: string, - closingId: string, - counts: PaymentTotals, - userId: string - ): Promise> { - const closing = await this.repository.findOne({ - where: { id: closingId, tenantId }, - }); - - if (!closing) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Closing not found' }, - }; - } - - closing.countedCard = counts.card; - closing.countedTransfer = counts.transfer; - closing.countedOther = counts.other; - closing.cardDifference = counts.card - Number(closing.expectedCard); - closing.countedTotal = (closing.countedCash ?? 0) + counts.card + counts.transfer + counts.other; - closing.totalDifference = Number(closing.countedTotal) - Number(closing.expectedTotal); - - // Build payment breakdown - closing.paymentBreakdown = [ - { - method: 'cash', - expected: Number(closing.expectedCash), - counted: Number(closing.countedCash ?? 0), - difference: Number(closing.cashDifference ?? 0), - transactionCount: 0, - }, - { - method: 'card', - expected: Number(closing.expectedCard), - counted: counts.card, - difference: counts.card - Number(closing.expectedCard), - transactionCount: 0, - }, - { - method: 'transfer', - expected: Number(closing.expectedTransfer), - counted: counts.transfer, - difference: counts.transfer - Number(closing.expectedTransfer), - transactionCount: 0, - }, - { - method: 'other', - expected: Number(closing.expectedOther), - counted: counts.other, - difference: counts.other - Number(closing.expectedOther), - transactionCount: 0, - }, - ]; - - const saved = await this.repository.save(closing); - return { success: true, data: saved }; - } - - /** - * Approve a closing - */ - async approveClosing( - tenantId: string, - closingId: string, - approverId: string, - notes?: string - ): Promise> { - const closing = await this.repository.findOne({ - where: { id: closingId, tenantId }, - }); - - if (!closing) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Closing not found' }, - }; - } - - if (closing.status !== ClosingStatus.PENDING_REVIEW) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Closing is not pending review' }, - }; - } - - closing.status = ClosingStatus.APPROVED; - closing.approvedBy = approverId; - closing.approvedAt = new Date(); - if (notes) { - closing.reviewNotes = notes; - } - - const saved = await this.repository.save(closing); - return { success: true, data: saved }; - } - - /** - * Reject a closing - */ - async rejectClosing( - tenantId: string, - closingId: string, - reviewerId: string, - notes: string - ): Promise> { - const closing = await this.repository.findOne({ - where: { id: closingId, tenantId }, - }); - - if (!closing) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Closing not found' }, - }; - } - - if (closing.status !== ClosingStatus.PENDING_REVIEW) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Closing is not pending review' }, - }; - } - - closing.status = ClosingStatus.REJECTED; - closing.reviewedBy = reviewerId; - closing.reviewedAt = new Date(); - closing.reviewNotes = notes; - - const saved = await this.repository.save(closing); - return { success: true, data: saved }; - } - - /** - * Mark closing as reconciled - */ - async reconcileClosing( - tenantId: string, - closingId: string, - depositInfo: { amount: number; reference: string; date: Date }, - userId: string - ): Promise> { - const closing = await this.repository.findOne({ - where: { id: closingId, tenantId }, - }); - - if (!closing) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Closing not found' }, - }; - } - - if (closing.status !== ClosingStatus.APPROVED) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Closing must be approved before reconciliation' }, - }; - } - - closing.status = ClosingStatus.RECONCILED; - closing.depositAmount = depositInfo.amount; - closing.depositReference = depositInfo.reference; - closing.depositDate = depositInfo.date; - - const saved = await this.repository.save(closing); - return { success: true, data: saved }; - } - - /** - * Find closings with filters - */ - async findClosings( - tenantId: string, - options: ClosingQueryOptions - ): Promise<{ data: CashClosing[]; total: number }> { - const qb = this.repository.createQueryBuilder('closing') - .where('closing.tenantId = :tenantId', { tenantId }); - - if (options.branchId) { - qb.andWhere('closing.branchId = :branchId', { branchId: options.branchId }); - } - if (options.sessionId) { - qb.andWhere('closing.sessionId = :sessionId', { sessionId: options.sessionId }); - } - if (options.status) { - qb.andWhere('closing.status = :status', { status: options.status }); - } - if (options.type) { - qb.andWhere('closing.type = :type', { type: options.type }); - } - if (options.startDate && options.endDate) { - qb.andWhere('closing.closingDate BETWEEN :startDate AND :endDate', { - startDate: options.startDate, - endDate: options.endDate, - }); - } - - const page = options.page ?? 1; - const limit = options.limit ?? 20; - qb.skip((page - 1) * limit).take(limit); - qb.orderBy('closing.closingDate', 'DESC'); - - const [data, total] = await qb.getManyAndCount(); - return { data, total }; - } - - /** - * Get closing with cash counts - */ - async getClosingWithCounts( - tenantId: string, - closingId: string - ): Promise { - return this.repository.findOne({ - where: { id: closingId, tenantId }, - relations: ['cashCounts'], - }); - } - - /** - * Get daily summary for branch - */ - async getDailySummary( - tenantId: string, - branchId: string, - date: Date - ): Promise<{ - closings: CashClosing[]; - totalSales: number; - totalRefunds: number; - netSales: number; - totalCashIn: number; - totalCashOut: number; - expectedDeposit: number; - }> { - const startOfDay = new Date(date); - startOfDay.setHours(0, 0, 0, 0); - const endOfDay = new Date(date); - endOfDay.setHours(23, 59, 59, 999); - - const closings = await this.repository.find({ - where: { - tenantId, - branchId, - closingDate: Between(startOfDay, endOfDay), - status: ClosingStatus.APPROVED, - }, - }); - - const summary = closings.reduce( - (acc, c) => ({ - totalSales: acc.totalSales + Number(c.totalSales), - totalRefunds: acc.totalRefunds + Number(c.totalRefunds), - netSales: acc.netSales + Number(c.netSales), - totalCashIn: acc.totalCashIn + Number(c.cashInTotal), - totalCashOut: acc.totalCashOut + Number(c.cashOutTotal), - }), - { totalSales: 0, totalRefunds: 0, netSales: 0, totalCashIn: 0, totalCashOut: 0 } - ); - - return { - closings, - ...summary, - expectedDeposit: summary.totalCashIn - summary.totalCashOut, - }; - } -} diff --git a/backend/src/modules/cash/services/cash-movement.service.ts b/backend/src/modules/cash/services/cash-movement.service.ts deleted file mode 100644 index c1ec2dd..0000000 --- a/backend/src/modules/cash/services/cash-movement.service.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { Repository, DataSource, Between, In } from 'typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult, QueryOptions } from '../../../shared/types'; -import { - CashMovement, - MovementType, - MovementReason, - MovementStatus, -} from '../entities/cash-movement.entity'; - -export interface CreateMovementInput { - branchId: string; - registerId: string; - sessionId: string; - type: MovementType; - reason?: MovementReason; - amount: number; - description: string; - referenceType?: string; - referenceId?: string; - referenceNumber?: string; - requiresApproval?: boolean; - recipientName?: string; - recipientId?: string; - accountId?: string; - bankAccount?: string; - depositSlipNumber?: string; - notes?: string; - metadata?: Record; -} - -export interface MovementQueryOptions extends QueryOptions { - branchId?: string; - sessionId?: string; - registerId?: string; - type?: MovementType; - reason?: MovementReason; - status?: MovementStatus; - startDate?: Date; - endDate?: Date; -} - -export class CashMovementService extends BaseService { - constructor( - repository: Repository, - private readonly dataSource: DataSource - ) { - super(repository); - } - - /** - * Generate a unique movement number - */ - private async generateMovementNumber(tenantId: string, branchId: string): Promise { - const today = new Date(); - const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); - - const count = await this.repository.count({ - where: { - tenantId, - branchId, - createdAt: Between( - new Date(today.setHours(0, 0, 0, 0)), - new Date(today.setHours(23, 59, 59, 999)) - ), - }, - }); - - return `MOV-${datePrefix}-${String(count + 1).padStart(4, '0')}`; - } - - /** - * Calculate running balance after movement - */ - private async calculateBalance( - tenantId: string, - sessionId: string, - amount: number, - type: MovementType - ): Promise { - const lastMovement = await this.repository.findOne({ - where: { tenantId, sessionId }, - order: { createdAt: 'DESC' }, - }); - - const previousBalance = lastMovement?.balanceAfter ?? 0; - const isInflow = [MovementType.CASH_IN, MovementType.OPENING].includes(type); - - return isInflow ? previousBalance + amount : previousBalance - amount; - } - - /** - * Create a cash movement - */ - async createMovement( - tenantId: string, - input: CreateMovementInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const number = await this.generateMovementNumber(tenantId, input.branchId); - - // Determine if approval is required based on amount thresholds - const approvalThreshold = 5000; // Could be configurable per branch - const requiresApproval = input.requiresApproval ?? - (input.type === MovementType.CASH_OUT && input.amount >= approvalThreshold); - - const balanceAfter = await this.calculateBalance( - tenantId, - input.sessionId, - input.amount, - input.type - ); - - const movement = queryRunner.manager.create(CashMovement, { - tenantId, - branchId: input.branchId, - registerId: input.registerId, - sessionId: input.sessionId, - number, - type: input.type, - reason: input.reason ?? MovementReason.OTHER, - status: requiresApproval ? MovementStatus.PENDING : MovementStatus.APPROVED, - amount: Math.abs(input.amount), - balanceAfter, - description: input.description, - referenceType: input.referenceType, - referenceId: input.referenceId, - referenceNumber: input.referenceNumber, - requiresApproval, - recipientName: input.recipientName, - recipientId: input.recipientId, - accountId: input.accountId, - bankAccount: input.bankAccount, - depositSlipNumber: input.depositSlipNumber, - createdBy: userId, - notes: input.notes, - metadata: input.metadata, - }); - - const savedMovement = await queryRunner.manager.save(movement); - await queryRunner.commitTransaction(); - - return { success: true, data: savedMovement }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_MOVEMENT_ERROR', - message: error instanceof Error ? error.message : 'Failed to create movement', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Find movements with extended filters - */ - async findMovements( - tenantId: string, - options: MovementQueryOptions - ): Promise<{ data: CashMovement[]; total: number }> { - const qb = this.repository.createQueryBuilder('movement') - .where('movement.tenantId = :tenantId', { tenantId }); - - if (options.branchId) { - qb.andWhere('movement.branchId = :branchId', { branchId: options.branchId }); - } - if (options.sessionId) { - qb.andWhere('movement.sessionId = :sessionId', { sessionId: options.sessionId }); - } - if (options.registerId) { - qb.andWhere('movement.registerId = :registerId', { registerId: options.registerId }); - } - if (options.type) { - qb.andWhere('movement.type = :type', { type: options.type }); - } - if (options.reason) { - qb.andWhere('movement.reason = :reason', { reason: options.reason }); - } - if (options.status) { - qb.andWhere('movement.status = :status', { status: options.status }); - } - if (options.startDate && options.endDate) { - qb.andWhere('movement.createdAt BETWEEN :startDate AND :endDate', { - startDate: options.startDate, - endDate: options.endDate, - }); - } - - const page = options.page ?? 1; - const limit = options.limit ?? 20; - qb.skip((page - 1) * limit).take(limit); - qb.orderBy('movement.createdAt', 'DESC'); - - const [data, total] = await qb.getManyAndCount(); - return { data, total }; - } - - /** - * Approve a pending movement - */ - async approveMovement( - tenantId: string, - id: string, - approverId: string - ): Promise> { - const movement = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!movement) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Movement not found' }, - }; - } - - if (movement.status !== MovementStatus.PENDING) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Movement is not pending approval' }, - }; - } - - movement.status = MovementStatus.APPROVED; - movement.approvedBy = approverId; - movement.approvedAt = new Date(); - - const saved = await this.repository.save(movement); - return { success: true, data: saved }; - } - - /** - * Reject a pending movement - */ - async rejectMovement( - tenantId: string, - id: string, - approverId: string, - reason: string - ): Promise> { - const movement = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!movement) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Movement not found' }, - }; - } - - if (movement.status !== MovementStatus.PENDING) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Movement is not pending approval' }, - }; - } - - movement.status = MovementStatus.REJECTED; - movement.approvedBy = approverId; - movement.approvedAt = new Date(); - movement.rejectionReason = reason; - - const saved = await this.repository.save(movement); - return { success: true, data: saved }; - } - - /** - * Cancel a movement - */ - async cancelMovement( - tenantId: string, - id: string, - userId: string, - reason: string - ): Promise> { - const movement = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!movement) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Movement not found' }, - }; - } - - if (movement.status === MovementStatus.CANCELLED) { - return { - success: false, - error: { code: 'ALREADY_CANCELLED', message: 'Movement is already cancelled' }, - }; - } - - movement.status = MovementStatus.CANCELLED; - movement.notes = `${movement.notes ?? ''}\nCancelled by ${userId}: ${reason}`.trim(); - - const saved = await this.repository.save(movement); - return { success: true, data: saved }; - } - - /** - * Get session cash balance - */ - async getSessionBalance(tenantId: string, sessionId: string): Promise { - const lastMovement = await this.repository.findOne({ - where: { tenantId, sessionId, status: MovementStatus.APPROVED }, - order: { createdAt: 'DESC' }, - }); - - return lastMovement?.balanceAfter ?? 0; - } - - /** - * Get session movements summary - */ - async getSessionSummary( - tenantId: string, - sessionId: string - ): Promise<{ - totalIn: number; - totalOut: number; - balance: number; - byType: Record; - byReason: Record; - pendingCount: number; - }> { - const movements = await this.repository.find({ - where: { tenantId, sessionId, status: MovementStatus.APPROVED }, - }); - - const byType: Record = {}; - const byReason: Record = {}; - let totalIn = 0; - let totalOut = 0; - - for (const m of movements) { - byType[m.type] = (byType[m.type] ?? 0) + Number(m.amount); - byReason[m.reason] = (byReason[m.reason] ?? 0) + Number(m.amount); - - if ([MovementType.CASH_IN, MovementType.OPENING].includes(m.type)) { - totalIn += Number(m.amount); - } else { - totalOut += Number(m.amount); - } - } - - const pendingCount = await this.repository.count({ - where: { tenantId, sessionId, status: MovementStatus.PENDING }, - }); - - return { - totalIn, - totalOut, - balance: totalIn - totalOut, - byType: byType as Record, - byReason: byReason as Record, - pendingCount, - }; - } - - /** - * Get movements by date range for reports - */ - async getMovementsForReport( - tenantId: string, - branchId: string, - startDate: Date, - endDate: Date, - types?: MovementType[] - ): Promise { - const qb = this.repository.createQueryBuilder('movement') - .where('movement.tenantId = :tenantId', { tenantId }) - .andWhere('movement.branchId = :branchId', { branchId }) - .andWhere('movement.status = :status', { status: MovementStatus.APPROVED }) - .andWhere('movement.createdAt BETWEEN :startDate AND :endDate', { - startDate, - endDate, - }); - - if (types && types.length > 0) { - qb.andWhere('movement.type IN (:...types)', { types }); - } - - qb.orderBy('movement.createdAt', 'ASC'); - - return qb.getMany(); - } -} diff --git a/backend/src/modules/cash/services/index.ts b/backend/src/modules/cash/services/index.ts deleted file mode 100644 index 4f1220e..0000000 --- a/backend/src/modules/cash/services/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './cash-movement.service'; -export * from './cash-closing.service'; diff --git a/backend/src/modules/cash/validation/cash.schema.ts b/backend/src/modules/cash/validation/cash.schema.ts deleted file mode 100644 index e702154..0000000 --- a/backend/src/modules/cash/validation/cash.schema.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { z } from 'zod'; -import { - uuidSchema, - moneySchema, - paginationSchema, -} from '../../../shared/validation/common.schema'; - -// Enums -export const movementTypeEnum = z.enum([ - 'cash_in', - 'cash_out', - 'deposit', - 'withdrawal', - 'adjustment', - 'opening', - 'closing', -]); - -export const movementReasonEnum = z.enum([ - 'change_fund', - 'petty_cash', - 'bank_deposit', - 'supplier_payment', - 'expense', - 'salary_advance', - 'correction', - 'other', -]); - -export const movementStatusEnum = z.enum([ - 'pending', - 'approved', - 'rejected', - 'cancelled', -]); - -export const closingTypeEnum = z.enum(['shift', 'daily', 'weekly', 'monthly']); -export const closingStatusEnum = z.enum([ - 'in_progress', - 'pending_review', - 'approved', - 'rejected', - 'reconciled', -]); - -export const denominationTypeEnum = z.enum(['bill', 'coin']); - -// ==================== MOVEMENT SCHEMAS ==================== - -// Create movement schema -export const createMovementSchema = z.object({ - branchId: uuidSchema, - registerId: uuidSchema, - sessionId: uuidSchema, - type: movementTypeEnum, - reason: movementReasonEnum.optional(), - amount: moneySchema.positive('Amount must be positive'), - description: z.string().min(1, 'Description is required').max(500), - referenceType: z.string().max(50).optional(), - referenceId: uuidSchema.optional(), - referenceNumber: z.string().max(50).optional(), - requiresApproval: z.boolean().optional(), - recipientName: z.string().max(200).optional(), - recipientId: uuidSchema.optional(), - accountId: uuidSchema.optional(), - bankAccount: z.string().max(50).optional(), - depositSlipNumber: z.string().max(50).optional(), - notes: z.string().max(1000).optional(), - metadata: z.record(z.any()).optional(), -}); - -// Approve/reject movement schema -export const movementActionSchema = z.object({ - reason: z.string().max(255).optional(), -}); - -// List movements query schema -export const listMovementsQuerySchema = paginationSchema.extend({ - branchId: uuidSchema.optional(), - sessionId: uuidSchema.optional(), - registerId: uuidSchema.optional(), - type: movementTypeEnum.optional(), - reason: movementReasonEnum.optional(), - status: movementStatusEnum.optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), -}); - -// ==================== CLOSING SCHEMAS ==================== - -// Create closing schema -export const createClosingSchema = z.object({ - branchId: uuidSchema, - registerId: uuidSchema.optional(), - sessionId: uuidSchema.optional(), - type: closingTypeEnum, - periodStart: z.coerce.date(), - periodEnd: z.coerce.date(), - openingBalance: moneySchema.default(0), - closingNotes: z.string().max(1000).optional(), -}).refine(data => data.periodStart <= data.periodEnd, { - message: 'Period start must be before or equal to period end', - path: ['periodEnd'], -}); - -// Denomination count schema -export const denominationCountSchema = z.object({ - type: denominationTypeEnum, - denomination: z.number().positive(), - quantity: z.number().int().min(0), -}); - -// Submit cash count schema -export const submitCashCountSchema = z.object({ - denominations: z.array(denominationCountSchema).min(1, 'At least one denomination is required'), -}); - -// Submit payment counts schema -export const submitPaymentCountsSchema = z.object({ - cash: moneySchema.optional(), - card: moneySchema.default(0), - transfer: moneySchema.default(0), - other: moneySchema.default(0), -}); - -// Closing action schema (approve/reject) -export const closingActionSchema = z.object({ - notes: z.string().max(1000).optional(), -}); - -// Reject closing schema (requires notes) -export const rejectClosingSchema = z.object({ - notes: z.string().min(1, 'Rejection reason is required').max(1000), -}); - -// Reconcile closing schema -export const reconcileClosingSchema = z.object({ - depositAmount: moneySchema.positive('Deposit amount must be positive'), - depositReference: z.string().min(1).max(100), - depositDate: z.coerce.date(), -}); - -// List closings query schema -export const listClosingsQuerySchema = paginationSchema.extend({ - branchId: uuidSchema.optional(), - sessionId: uuidSchema.optional(), - status: closingStatusEnum.optional(), - type: closingTypeEnum.optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), -}); - -// Types -export type CreateMovementInput = z.infer; -export type ListMovementsQuery = z.infer; -export type CreateClosingInput = z.infer; -export type SubmitCashCountInput = z.infer; -export type SubmitPaymentCountsInput = z.infer; -export type ReconcileClosingInput = z.infer; -export type ListClosingsQuery = z.infer; diff --git a/backend/src/modules/customers/controllers/loyalty.controller.ts b/backend/src/modules/customers/controllers/loyalty.controller.ts deleted file mode 100644 index 409b078..0000000 --- a/backend/src/modules/customers/controllers/loyalty.controller.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { Request, Response } from 'express'; -import { BaseController } from '../../../shared/controllers/base.controller'; -import { LoyaltyService, MembershipQueryOptions } from '../services/loyalty.service'; - -export class LoyaltyController extends BaseController { - constructor(private readonly loyaltyService: LoyaltyService) { - super(); - } - - // ==================== PROGRAM ENDPOINTS ==================== - - /** - * Get active loyalty program - * GET /loyalty/program - */ - getActiveProgram = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - - const program = await this.loyaltyService.getActiveProgram(tenantId); - - if (!program) { - this.notFound(res, 'Active loyalty program'); - return; - } - - this.success(res, program); - }; - - // ==================== MEMBERSHIP ENDPOINTS ==================== - - /** - * Enroll customer in loyalty program - * POST /loyalty/enroll - */ - enrollCustomer = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - - const result = await this.loyaltyService.enrollCustomer(tenantId, req.body, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.created(res, result.data); - }; - - /** - * List memberships - * GET /loyalty/memberships - */ - listMemberships = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const options: MembershipQueryOptions = { - page: Number(req.query.page) || 1, - limit: Number(req.query.limit) || 20, - programId: req.query.programId as string, - levelId: req.query.levelId as string, - status: req.query.status as any, - customerId: req.query.customerId as string, - }; - - const result = await this.loyaltyService.findMemberships(tenantId, options); - this.paginated(res, result.data, result.total, options.page!, options.limit!); - }; - - /** - * Get membership by customer ID - * GET /loyalty/memberships/customer/:customerId - */ - getMembershipByCustomer = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const { customerId } = req.params; - const programId = req.query.programId as string; - - const membership = await this.loyaltyService.getMembershipByCustomer( - tenantId, - customerId, - programId - ); - - if (!membership) { - this.notFound(res, 'Membership'); - return; - } - - this.success(res, membership); - }; - - /** - * Get membership by card number - * GET /loyalty/memberships/card/:cardNumber - */ - getMembershipByCard = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const { cardNumber } = req.params; - - const membership = await this.loyaltyService.getMembershipByCard(tenantId, cardNumber); - - if (!membership) { - this.notFound(res, 'Membership'); - return; - } - - this.success(res, membership); - }; - - // ==================== POINTS ENDPOINTS ==================== - - /** - * Calculate points preview - * POST /loyalty/points/calculate - */ - calculatePoints = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const { membershipId, purchaseAmount, categoryMultipliers } = req.body; - - const membership = await this.loyaltyService.getMembershipByCustomer(tenantId, membershipId); - if (!membership) { - this.error(res, 'Membership not found', 404); - return; - } - - const result = await this.loyaltyService.calculatePoints( - tenantId, - membership.programId, - membershipId, - purchaseAmount, - categoryMultipliers - ); - - this.success(res, result); - }; - - /** - * Earn points - * POST /loyalty/points/earn - */ - earnPoints = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - - const result = await this.loyaltyService.earnPoints(tenantId, req.body, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Points earned successfully'); - }; - - /** - * Redeem points - * POST /loyalty/points/redeem - */ - redeemPoints = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - - const result = await this.loyaltyService.redeemPoints(tenantId, req.body, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Points redeemed successfully'); - }; - - /** - * Adjust points - * POST /loyalty/points/adjust - */ - adjustPoints = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - - const result = await this.loyaltyService.adjustPoints(tenantId, req.body, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Points adjusted'); - }; - - /** - * Get expiring points - * GET /loyalty/memberships/:membershipId/expiring - */ - getExpiringPoints = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const { membershipId } = req.params; - const daysAhead = Number(req.query.days) || 30; - - const result = await this.loyaltyService.getExpiringPoints(tenantId, membershipId, daysAhead); - this.success(res, result); - }; - - // ==================== TRANSACTION ENDPOINTS ==================== - - /** - * Get transaction history - * GET /loyalty/memberships/:membershipId/transactions - */ - getTransactionHistory = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const { membershipId } = req.params; - - const options = { - page: Number(req.query.page) || 1, - limit: Number(req.query.limit) || 20, - type: req.query.type as any, - }; - - const result = await this.loyaltyService.getTransactionHistory(tenantId, membershipId, options); - this.paginated(res, result.data, result.total, options.page, options.limit); - }; -} diff --git a/backend/src/modules/customers/entities/customer-membership.entity.ts b/backend/src/modules/customers/entities/customer-membership.entity.ts deleted file mode 100644 index 75ee66a..0000000 --- a/backend/src/modules/customers/entities/customer-membership.entity.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index, -} from 'typeorm'; - -export enum MembershipStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', - SUSPENDED = 'suspended', - EXPIRED = 'expired', -} - -@Entity('customer_memberships', { schema: 'retail' }) -@Index(['tenantId', 'customerId', 'programId'], { unique: true }) -@Index(['tenantId', 'cardNumber'], { unique: true }) -@Index(['tenantId', 'status']) -@Index(['tenantId', 'levelId']) -export class CustomerMembership { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - // Customer (from erp-core partners) - @Column({ name: 'customer_id', type: 'uuid' }) - customerId: string; - - @Column({ name: 'program_id', type: 'uuid' }) - programId: string; - - @Column({ name: 'level_id', type: 'uuid' }) - levelId: string; - - @Column({ name: 'card_number', length: 20, unique: true }) - cardNumber: string; - - @Column({ - type: 'enum', - enum: MembershipStatus, - default: MembershipStatus.ACTIVE, - }) - status: MembershipStatus; - - // Points - @Column({ name: 'points_balance', type: 'int', default: 0 }) - pointsBalance: number; - - @Column({ name: 'lifetime_points', type: 'int', default: 0 }) - lifetimePoints: number; - - @Column({ name: 'points_redeemed', type: 'int', default: 0 }) - pointsRedeemed: number; - - @Column({ name: 'points_expired', type: 'int', default: 0 }) - pointsExpired: number; - - @Column({ name: 'points_expiring_soon', type: 'int', default: 0 }) - pointsExpiringSoon: number; - - @Column({ name: 'next_expiration_date', type: 'date', nullable: true }) - nextExpirationDate: Date; - - @Column({ name: 'next_expiration_points', type: 'int', default: 0 }) - nextExpirationPoints: number; - - // Purchase stats - @Column({ name: 'total_purchases', type: 'int', default: 0 }) - totalPurchases: number; - - @Column({ name: 'total_spend', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalSpend: number; - - @Column({ name: 'average_order_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - averageOrderValue: number; - - // Period stats (for level qualification) - @Column({ name: 'period_purchases', type: 'int', default: 0 }) - periodPurchases: number; - - @Column({ name: 'period_spend', type: 'decimal', precision: 15, scale: 2, default: 0 }) - periodSpend: number; - - @Column({ name: 'period_points', type: 'int', default: 0 }) - periodPoints: number; - - @Column({ name: 'period_start_date', type: 'date', nullable: true }) - periodStartDate: Date; - - @Column({ name: 'period_end_date', type: 'date', nullable: true }) - periodEndDate: Date; - - // Level history - @Column({ name: 'level_achieved_at', type: 'timestamp with time zone', nullable: true }) - levelAchievedAt: Date; - - @Column({ name: 'level_expires_at', type: 'date', nullable: true }) - levelExpiresAt: Date; - - @Column({ name: 'previous_level_id', type: 'uuid', nullable: true }) - previousLevelId: string; - - // Dates - @Column({ name: 'enrolled_at', type: 'timestamp with time zone' }) - enrolledAt: Date; - - @Column({ name: 'last_activity_at', type: 'timestamp with time zone', nullable: true }) - lastActivityAt: Date; - - @Column({ name: 'last_purchase_at', type: 'timestamp with time zone', nullable: true }) - lastPurchaseAt: Date; - - @Column({ name: 'last_redemption_at', type: 'timestamp with time zone', nullable: true }) - lastRedemptionAt: Date; - - // Referral - @Column({ name: 'referral_code', length: 20, nullable: true }) - referralCode: string; - - @Column({ name: 'referred_by_id', type: 'uuid', nullable: true }) - referredById: string; - - @Column({ name: 'referrals_count', type: 'int', default: 0 }) - referralsCount: number; - - // Communication preferences - @Column({ name: 'email_notifications', type: 'boolean', default: true }) - emailNotifications: boolean; - - @Column({ name: 'sms_notifications', type: 'boolean', default: false }) - smsNotifications: boolean; - - @Column({ name: 'push_notifications', type: 'boolean', default: true }) - pushNotifications: boolean; - - // Favorite branch - @Column({ name: 'favorite_branch_id', type: 'uuid', nullable: true }) - favoriteBranchId: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; -} diff --git a/backend/src/modules/customers/entities/index.ts b/backend/src/modules/customers/entities/index.ts deleted file mode 100644 index f0b523e..0000000 --- a/backend/src/modules/customers/entities/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './loyalty-program.entity'; -export * from './membership-level.entity'; -export * from './loyalty-transaction.entity'; -export * from './customer-membership.entity'; diff --git a/backend/src/modules/customers/entities/loyalty-program.entity.ts b/backend/src/modules/customers/entities/loyalty-program.entity.ts deleted file mode 100644 index d783728..0000000 --- a/backend/src/modules/customers/entities/loyalty-program.entity.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { MembershipLevel } from './membership-level.entity'; - -export enum ProgramStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', - SUSPENDED = 'suspended', -} - -export enum PointsCalculation { - FIXED_PER_PURCHASE = 'fixed_per_purchase', - PERCENTAGE_OF_AMOUNT = 'percentage_of_amount', - POINTS_PER_CURRENCY = 'points_per_currency', -} - -@Entity('loyalty_programs', { schema: 'retail' }) -@Index(['tenantId', 'status']) -@Index(['tenantId', 'code'], { unique: true }) -export class LoyaltyProgram { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ length: 20, unique: true }) - code: string; - - @Column({ length: 100 }) - name: string; - - @Column({ type: 'text', nullable: true }) - description: string; - - @Column({ - type: 'enum', - enum: ProgramStatus, - default: ProgramStatus.ACTIVE, - }) - status: ProgramStatus; - - // Points configuration - @Column({ - name: 'points_calculation', - type: 'enum', - enum: PointsCalculation, - default: PointsCalculation.POINTS_PER_CURRENCY, - }) - pointsCalculation: PointsCalculation; - - @Column({ name: 'points_per_currency', type: 'decimal', precision: 10, scale: 4, default: 1 }) - pointsPerCurrency: number; // e.g., 1 point per $10 MXN - - @Column({ name: 'currency_per_point', type: 'decimal', precision: 10, scale: 2, default: 10 }) - currencyPerPoint: number; // e.g., $10 MXN = 1 point - - @Column({ name: 'minimum_purchase', type: 'decimal', precision: 15, scale: 2, default: 0 }) - minimumPurchase: number; - - @Column({ name: 'round_points', type: 'boolean', default: true }) - roundPoints: boolean; - - @Column({ name: 'round_direction', length: 10, default: 'down' }) - roundDirection: 'up' | 'down' | 'nearest'; - - // Redemption configuration - @Column({ name: 'points_value', type: 'decimal', precision: 10, scale: 4, default: 0.1 }) - pointsValue: number; // Value of 1 point in currency (e.g., 1 point = $0.10) - - @Column({ name: 'min_points_redemption', type: 'int', default: 100 }) - minPointsRedemption: number; - - @Column({ name: 'max_redemption_percent', type: 'decimal', precision: 5, scale: 2, default: 100 }) - maxRedemptionPercent: number; // Max % of purchase that can be paid with points - - @Column({ name: 'allow_partial_redemption', type: 'boolean', default: true }) - allowPartialRedemption: boolean; - - // Expiration - @Column({ name: 'points_expire', type: 'boolean', default: true }) - pointsExpire: boolean; - - @Column({ name: 'expiration_months', type: 'int', default: 12 }) - expirationMonths: number; - - @Column({ name: 'expiration_policy', length: 20, default: 'fifo' }) - expirationPolicy: 'fifo' | 'lifo' | 'oldest_first'; - - // Bonus configuration - @Column({ name: 'welcome_bonus', type: 'int', default: 0 }) - welcomeBonus: number; - - @Column({ name: 'birthday_bonus', type: 'int', default: 0 }) - birthdayBonus: number; - - @Column({ name: 'referral_bonus', type: 'int', default: 0 }) - referralBonus: number; - - @Column({ name: 'referee_bonus', type: 'int', default: 0 }) - refereeBonus: number; - - // Multipliers - @Column({ name: 'double_points_days', type: 'jsonb', nullable: true }) - doublePointsDays: string[]; // ['monday', 'tuesday', etc.] - - @Column({ name: 'category_multipliers', type: 'jsonb', nullable: true }) - categoryMultipliers: { - categoryId: string; - multiplier: number; - }[]; - - // Restrictions - @Column({ name: 'excluded_categories', type: 'jsonb', nullable: true }) - excludedCategories: string[]; - - @Column({ name: 'excluded_products', type: 'jsonb', nullable: true }) - excludedProducts: string[]; - - @Column({ name: 'included_branches', type: 'jsonb', nullable: true }) - includedBranches: string[]; // null = all branches - - // Valid dates - @Column({ name: 'valid_from', type: 'date', nullable: true }) - validFrom: Date; - - @Column({ name: 'valid_until', type: 'date', nullable: true }) - validUntil: Date; - - // Terms - @Column({ name: 'terms_and_conditions', type: 'text', nullable: true }) - termsAndConditions: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy: string; - - // Relations - @OneToMany(() => MembershipLevel, (level) => level.program) - levels: MembershipLevel[]; -} diff --git a/backend/src/modules/customers/entities/loyalty-transaction.entity.ts b/backend/src/modules/customers/entities/loyalty-transaction.entity.ts deleted file mode 100644 index 93caf3d..0000000 --- a/backend/src/modules/customers/entities/loyalty-transaction.entity.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - Index, -} from 'typeorm'; - -export enum TransactionType { - EARN = 'earn', - REDEEM = 'redeem', - EXPIRE = 'expire', - ADJUST = 'adjust', - BONUS = 'bonus', - TRANSFER_IN = 'transfer_in', - TRANSFER_OUT = 'transfer_out', - REFUND = 'refund', -} - -export enum TransactionStatus { - PENDING = 'pending', - COMPLETED = 'completed', - CANCELLED = 'cancelled', - REVERSED = 'reversed', -} - -@Entity('loyalty_transactions', { schema: 'retail' }) -@Index(['tenantId', 'customerId', 'createdAt']) -@Index(['tenantId', 'programId', 'type']) -@Index(['tenantId', 'membershipId']) -@Index(['tenantId', 'orderId']) -export class LoyaltyTransaction { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'program_id', type: 'uuid' }) - programId: string; - - @Column({ name: 'membership_id', type: 'uuid' }) - membershipId: string; - - @Column({ name: 'customer_id', type: 'uuid' }) - customerId: string; - - @Column({ length: 30, unique: true }) - number: string; - - @Column({ - type: 'enum', - enum: TransactionType, - }) - type: TransactionType; - - @Column({ - type: 'enum', - enum: TransactionStatus, - default: TransactionStatus.COMPLETED, - }) - status: TransactionStatus; - - // Points - @Column({ type: 'int' }) - points: number; // positive for earn, negative for redeem - - @Column({ name: 'points_balance_after', type: 'int' }) - pointsBalanceAfter: number; - - // Value - @Column({ name: 'monetary_value', type: 'decimal', precision: 15, scale: 2, nullable: true }) - monetaryValue: number; - - @Column({ name: 'purchase_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - purchaseAmount: number; - - // Reference - @Column({ name: 'order_id', type: 'uuid', nullable: true }) - orderId: string; - - @Column({ name: 'order_number', length: 30, nullable: true }) - orderNumber: string; - - @Column({ name: 'branch_id', type: 'uuid', nullable: true }) - branchId: string; - - // Bonus type (for bonus transactions) - @Column({ name: 'bonus_type', length: 30, nullable: true }) - bonusType: 'welcome' | 'birthday' | 'referral' | 'referee' | 'campaign' | 'manual'; - - @Column({ name: 'campaign_id', type: 'uuid', nullable: true }) - campaignId: string; - - // Multipliers applied - @Column({ name: 'base_points', type: 'int', nullable: true }) - basePoints: number; - - @Column({ name: 'multiplier_applied', type: 'decimal', precision: 5, scale: 2, nullable: true }) - multiplierApplied: number; - - @Column({ name: 'multiplier_reason', length: 100, nullable: true }) - multiplierReason: string; - - // Expiration - @Column({ name: 'expires_at', type: 'date', nullable: true }) - expiresAt: Date; - - @Column({ name: 'expired', type: 'boolean', default: false }) - expired: boolean; - - // For transfers - @Column({ name: 'transfer_to_customer_id', type: 'uuid', nullable: true }) - transferToCustomerId: string; - - @Column({ name: 'transfer_from_customer_id', type: 'uuid', nullable: true }) - transferFromCustomerId: string; - - // Description - @Column({ type: 'text', nullable: true }) - description: string; - - // Reversal - @Column({ name: 'reversed_by_id', type: 'uuid', nullable: true }) - reversedById: string; - - @Column({ name: 'reversal_of_id', type: 'uuid', nullable: true }) - reversalOfId: string; - - @Column({ name: 'reversal_reason', length: 255, nullable: true }) - reversalReason: string; - - // User - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} diff --git a/backend/src/modules/customers/entities/membership-level.entity.ts b/backend/src/modules/customers/entities/membership-level.entity.ts deleted file mode 100644 index fb18d57..0000000 --- a/backend/src/modules/customers/entities/membership-level.entity.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { LoyaltyProgram } from './loyalty-program.entity'; - -export enum LevelStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', -} - -@Entity('membership_levels', { schema: 'retail' }) -@Index(['tenantId', 'programId', 'level']) -@Index(['tenantId', 'code'], { unique: true }) -export class MembershipLevel { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'program_id', type: 'uuid' }) - programId: string; - - @Column({ length: 20, unique: true }) - code: string; - - @Column({ length: 50 }) - name: string; - - @Column({ type: 'text', nullable: true }) - description: string; - - @Column({ type: 'int', default: 0 }) - level: number; // 0 = base level, higher = better - - @Column({ - type: 'enum', - enum: LevelStatus, - default: LevelStatus.ACTIVE, - }) - status: LevelStatus; - - // Qualification criteria - @Column({ name: 'min_points_required', type: 'int', default: 0 }) - minPointsRequired: number; - - @Column({ name: 'min_purchases_required', type: 'int', default: 0 }) - minPurchasesRequired: number; - - @Column({ name: 'min_spend_required', type: 'decimal', precision: 15, scale: 2, default: 0 }) - minSpendRequired: number; - - @Column({ name: 'qualification_period_months', type: 'int', default: 12 }) - qualificationPeriodMonths: number; - - // Benefits - @Column({ name: 'points_multiplier', type: 'decimal', precision: 5, scale: 2, default: 1 }) - pointsMultiplier: number; - - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) - discountPercent: number; - - @Column({ name: 'free_shipping', type: 'boolean', default: false }) - freeShipping: boolean; - - @Column({ name: 'free_shipping_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true }) - freeShippingThreshold: number; - - @Column({ name: 'priority_support', type: 'boolean', default: false }) - prioritySupport: boolean; - - @Column({ name: 'early_access', type: 'boolean', default: false }) - earlyAccess: boolean; - - @Column({ name: 'exclusive_promotions', type: 'boolean', default: false }) - exclusivePromotions: boolean; - - @Column({ name: 'birthday_multiplier', type: 'decimal', precision: 5, scale: 2, default: 1 }) - birthdayMultiplier: number; - - // Custom benefits - @Column({ name: 'custom_benefits', type: 'jsonb', nullable: true }) - customBenefits: { - name: string; - description: string; - value?: string; - }[]; - - // Display - @Column({ length: 7, default: '#000000' }) - color: string; - - @Column({ name: 'icon_url', length: 255, nullable: true }) - iconUrl: string; - - @Column({ name: 'badge_url', length: 255, nullable: true }) - badgeUrl: string; - - // Retention - @Column({ name: 'retention_months', type: 'int', default: 12 }) - retentionMonths: number; - - @Column({ name: 'downgrade_level_id', type: 'uuid', nullable: true }) - downgradeLevelId: string; - - // Auto-upgrade - @Column({ name: 'auto_upgrade', type: 'boolean', default: true }) - autoUpgrade: boolean; - - @Column({ name: 'upgrade_notification', type: 'boolean', default: true }) - upgradeNotification: boolean; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - // Relations - @ManyToOne(() => LoyaltyProgram, (program) => program.levels) - @JoinColumn({ name: 'program_id' }) - program: LoyaltyProgram; -} diff --git a/backend/src/modules/customers/index.ts b/backend/src/modules/customers/index.ts deleted file mode 100644 index 1433b71..0000000 --- a/backend/src/modules/customers/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './entities'; -export * from './services'; -export * from './controllers/loyalty.controller'; -export * from './routes/loyalty.routes'; -export * from './validation/customers.schema'; diff --git a/backend/src/modules/customers/routes/loyalty.routes.ts b/backend/src/modules/customers/routes/loyalty.routes.ts deleted file mode 100644 index 070dda8..0000000 --- a/backend/src/modules/customers/routes/loyalty.routes.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Router } from 'express'; -import { LoyaltyController } from '../controllers/loyalty.controller'; -import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware'; -import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware'; -import { - enrollCustomerSchema, - listMembershipsQuerySchema, - earnPointsSchema, - redeemPointsSchema, - adjustPointsSchema, - calculatePointsSchema, - lookupByCardSchema, - listTransactionsQuerySchema, -} from '../validation/customers.schema'; - -export function createLoyaltyRoutes(controller: LoyaltyController): Router { - const router = Router(); - - // Apply auth middleware to all routes - router.use(requireAuth); - - // ==================== PROGRAM ROUTES ==================== - - // Get active program - router.get( - '/program', - requirePermissions(['loyalty.view']), - controller.getActiveProgram - ); - - // ==================== MEMBERSHIP ROUTES ==================== - - // Enroll customer - router.post( - '/enroll', - requirePermissions(['loyalty.enroll']), - validateBody(enrollCustomerSchema), - controller.enrollCustomer - ); - - // List memberships - router.get( - '/memberships', - requirePermissions(['loyalty.view']), - validateQuery(listMembershipsQuerySchema), - controller.listMemberships - ); - - // Get membership by customer - router.get( - '/memberships/customer/:customerId', - requirePermissions(['loyalty.view']), - controller.getMembershipByCustomer - ); - - // Get membership by card - router.get( - '/memberships/card/:cardNumber', - requirePermissions(['loyalty.view']), - controller.getMembershipByCard - ); - - // Get expiring points - router.get( - '/memberships/:membershipId/expiring', - requirePermissions(['loyalty.view']), - controller.getExpiringPoints - ); - - // Get transaction history - router.get( - '/memberships/:membershipId/transactions', - requirePermissions(['loyalty.view']), - validateQuery(listTransactionsQuerySchema), - controller.getTransactionHistory - ); - - // ==================== POINTS ROUTES ==================== - - // Calculate points preview - router.post( - '/points/calculate', - requirePermissions(['loyalty.view']), - validateBody(calculatePointsSchema), - controller.calculatePoints - ); - - // Earn points - router.post( - '/points/earn', - requirePermissions(['loyalty.earn']), - validateBody(earnPointsSchema), - controller.earnPoints - ); - - // Redeem points - router.post( - '/points/redeem', - requirePermissions(['loyalty.redeem']), - validateBody(redeemPointsSchema), - controller.redeemPoints - ); - - // Adjust points - router.post( - '/points/adjust', - requireRoles(['admin', 'manager']), - validateBody(adjustPointsSchema), - controller.adjustPoints - ); - - return router; -} diff --git a/backend/src/modules/customers/services/index.ts b/backend/src/modules/customers/services/index.ts deleted file mode 100644 index 14ac4dc..0000000 --- a/backend/src/modules/customers/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './loyalty.service'; diff --git a/backend/src/modules/customers/services/loyalty.service.ts b/backend/src/modules/customers/services/loyalty.service.ts deleted file mode 100644 index 4cbb3a5..0000000 --- a/backend/src/modules/customers/services/loyalty.service.ts +++ /dev/null @@ -1,842 +0,0 @@ -import { Repository, DataSource, Between, LessThanOrEqual, MoreThan } from 'typeorm'; -import { ServiceResult, QueryOptions } from '../../../shared/types'; -import { LoyaltyProgram, ProgramStatus, PointsCalculation } from '../entities/loyalty-program.entity'; -import { MembershipLevel, LevelStatus } from '../entities/membership-level.entity'; -import { CustomerMembership, MembershipStatus } from '../entities/customer-membership.entity'; -import { LoyaltyTransaction, TransactionType, TransactionStatus } from '../entities/loyalty-transaction.entity'; - -export interface EnrollCustomerInput { - customerId: string; - programId: string; - referredById?: string; - emailNotifications?: boolean; - smsNotifications?: boolean; -} - -export interface EarnPointsInput { - membershipId: string; - purchaseAmount: number; - orderId?: string; - orderNumber?: string; - branchId?: string; - categoryMultipliers?: { categoryId: string; amount: number }[]; -} - -export interface RedeemPointsInput { - membershipId: string; - points: number; - orderId?: string; - orderNumber?: string; - branchId?: string; - description?: string; -} - -export interface AdjustPointsInput { - membershipId: string; - points: number; - reason: string; -} - -export interface MembershipQueryOptions extends QueryOptions { - programId?: string; - levelId?: string; - status?: MembershipStatus; - customerId?: string; -} - -export class LoyaltyService { - constructor( - private readonly programRepository: Repository, - private readonly levelRepository: Repository, - private readonly membershipRepository: Repository, - private readonly transactionRepository: Repository, - private readonly dataSource: DataSource - ) {} - - /** - * Generate a unique card number - */ - private async generateCardNumber(tenantId: string): Promise { - const prefix = 'LYL'; - const random = Math.floor(Math.random() * 1000000000).toString().padStart(10, '0'); - const checkDigit = this.calculateLuhnCheckDigit(random); - return `${prefix}${random}${checkDigit}`; - } - - /** - * Calculate Luhn check digit - */ - private calculateLuhnCheckDigit(number: string): number { - let sum = 0; - let isEven = true; - for (let i = number.length - 1; i >= 0; i--) { - let digit = parseInt(number[i], 10); - if (isEven) { - digit *= 2; - if (digit > 9) digit -= 9; - } - sum += digit; - isEven = !isEven; - } - return (10 - (sum % 10)) % 10; - } - - /** - * Generate unique referral code - */ - private generateReferralCode(): string { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - let code = ''; - for (let i = 0; i < 8; i++) { - code += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return code; - } - - /** - * Generate transaction number - */ - private async generateTransactionNumber(tenantId: string): Promise { - const today = new Date(); - const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); - const count = await this.transactionRepository.count({ - where: { - tenantId, - createdAt: Between( - new Date(today.setHours(0, 0, 0, 0)), - new Date(today.setHours(23, 59, 59, 999)) - ), - }, - }); - return `LTX-${datePrefix}-${String(count + 1).padStart(5, '0')}`; - } - - /** - * Get active program for tenant - */ - async getActiveProgram(tenantId: string): Promise { - return this.programRepository.findOne({ - where: { - tenantId, - status: ProgramStatus.ACTIVE, - }, - relations: ['levels'], - }); - } - - /** - * Get base level for a program - */ - async getBaseLevel(tenantId: string, programId: string): Promise { - return this.levelRepository.findOne({ - where: { - tenantId, - programId, - level: 0, - status: LevelStatus.ACTIVE, - }, - }); - } - - /** - * Enroll a customer in loyalty program - */ - async enrollCustomer( - tenantId: string, - input: EnrollCustomerInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - // Check if already enrolled - const existing = await queryRunner.manager.findOne(CustomerMembership, { - where: { - tenantId, - customerId: input.customerId, - programId: input.programId, - }, - }); - - if (existing) { - return { - success: false, - error: { code: 'ALREADY_ENROLLED', message: 'Customer is already enrolled in this program' }, - }; - } - - // Get program and base level - const program = await queryRunner.manager.findOne(LoyaltyProgram, { - where: { id: input.programId, tenantId, status: ProgramStatus.ACTIVE }, - }); - - if (!program) { - return { - success: false, - error: { code: 'PROGRAM_NOT_FOUND', message: 'Active loyalty program not found' }, - }; - } - - const baseLevel = await queryRunner.manager.findOne(MembershipLevel, { - where: { tenantId, programId: input.programId, level: 0, status: LevelStatus.ACTIVE }, - }); - - if (!baseLevel) { - return { - success: false, - error: { code: 'NO_BASE_LEVEL', message: 'Program has no base level configured' }, - }; - } - - const cardNumber = await this.generateCardNumber(tenantId); - const referralCode = this.generateReferralCode(); - const now = new Date(); - - // Calculate period dates - const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); - const periodEnd = new Date(now.getFullYear() + 1, now.getMonth(), 0); - - const membership = queryRunner.manager.create(CustomerMembership, { - tenantId, - customerId: input.customerId, - programId: input.programId, - levelId: baseLevel.id, - cardNumber, - status: MembershipStatus.ACTIVE, - pointsBalance: program.welcomeBonus, - lifetimePoints: program.welcomeBonus, - enrolledAt: now, - referralCode, - referredById: input.referredById, - emailNotifications: input.emailNotifications ?? true, - smsNotifications: input.smsNotifications ?? false, - pushNotifications: true, - periodStartDate: periodStart, - periodEndDate: periodEnd, - createdBy: userId, - }); - - const savedMembership = await queryRunner.manager.save(membership); - - // Create welcome bonus transaction if applicable - if (program.welcomeBonus > 0) { - const txNumber = await this.generateTransactionNumber(tenantId); - const welcomeTx = queryRunner.manager.create(LoyaltyTransaction, { - tenantId, - programId: input.programId, - membershipId: savedMembership.id, - customerId: input.customerId, - number: txNumber, - type: TransactionType.BONUS, - status: TransactionStatus.COMPLETED, - points: program.welcomeBonus, - pointsBalanceAfter: program.welcomeBonus, - bonusType: 'welcome', - description: 'Welcome bonus', - createdBy: userId, - expiresAt: program.pointsExpire - ? new Date(now.getTime() + program.expirationMonths * 30 * 24 * 60 * 60 * 1000) - : null, - }); - await queryRunner.manager.save(welcomeTx); - } - - // Handle referral bonus - if (input.referredById && program.referralBonus > 0) { - const referrer = await queryRunner.manager.findOne(CustomerMembership, { - where: { tenantId, customerId: input.referredById, programId: input.programId }, - }); - - if (referrer) { - referrer.pointsBalance += program.referralBonus; - referrer.lifetimePoints += program.referralBonus; - referrer.referralsCount += 1; - await queryRunner.manager.save(referrer); - - const referralTxNumber = await this.generateTransactionNumber(tenantId); - const referralTx = queryRunner.manager.create(LoyaltyTransaction, { - tenantId, - programId: input.programId, - membershipId: referrer.id, - customerId: input.referredById, - number: referralTxNumber, - type: TransactionType.BONUS, - status: TransactionStatus.COMPLETED, - points: program.referralBonus, - pointsBalanceAfter: referrer.pointsBalance, - bonusType: 'referral', - description: `Referral bonus for referring customer`, - createdBy: userId, - }); - await queryRunner.manager.save(referralTx); - } - } - - await queryRunner.commitTransaction(); - return { success: true, data: savedMembership }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'ENROLL_ERROR', - message: error instanceof Error ? error.message : 'Failed to enroll customer', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Calculate points for a purchase - */ - async calculatePoints( - tenantId: string, - programId: string, - membershipId: string, - purchaseAmount: number, - categoryAmounts?: { categoryId: string; amount: number }[] - ): Promise<{ points: number; multiplier: number; breakdown: any }> { - const program = await this.programRepository.findOne({ - where: { id: programId, tenantId }, - }); - - if (!program) { - return { points: 0, multiplier: 1, breakdown: {} }; - } - - const membership = await this.membershipRepository.findOne({ - where: { id: membershipId, tenantId }, - }); - - if (!membership) { - return { points: 0, multiplier: 1, breakdown: {} }; - } - - const level = await this.levelRepository.findOne({ - where: { id: membership.levelId, tenantId }, - }); - - // Check minimum purchase - if (purchaseAmount < Number(program.minimumPurchase)) { - return { points: 0, multiplier: 1, breakdown: { reason: 'Below minimum purchase' } }; - } - - // Base points calculation - let basePoints = 0; - switch (program.pointsCalculation) { - case PointsCalculation.FIXED_PER_PURCHASE: - basePoints = Number(program.pointsPerCurrency); - break; - case PointsCalculation.PERCENTAGE_OF_AMOUNT: - basePoints = purchaseAmount * Number(program.pointsPerCurrency) / 100; - break; - case PointsCalculation.POINTS_PER_CURRENCY: - default: - basePoints = purchaseAmount / Number(program.currencyPerPoint); - break; - } - - // Apply level multiplier - const levelMultiplier = level ? Number(level.pointsMultiplier) : 1; - let totalMultiplier = levelMultiplier; - - // Check for double points day - const today = new Date().toLocaleDateString('en-US', { weekday: 'lowercase' }); - if (program.doublePointsDays?.includes(today)) { - totalMultiplier *= 2; - } - - // Apply category multipliers - let categoryBonusPoints = 0; - if (categoryAmounts && program.categoryMultipliers) { - for (const ca of categoryAmounts) { - const catMultiplier = program.categoryMultipliers.find(cm => cm.categoryId === ca.categoryId); - if (catMultiplier) { - const catPoints = (ca.amount / Number(program.currencyPerPoint)) * catMultiplier.multiplier; - categoryBonusPoints += catPoints; - } - } - } - - let totalPoints = basePoints * totalMultiplier + categoryBonusPoints; - - // Round points - if (program.roundPoints) { - switch (program.roundDirection) { - case 'up': - totalPoints = Math.ceil(totalPoints); - break; - case 'down': - totalPoints = Math.floor(totalPoints); - break; - case 'nearest': - default: - totalPoints = Math.round(totalPoints); - break; - } - } - - return { - points: totalPoints, - multiplier: totalMultiplier, - breakdown: { - basePoints, - levelMultiplier, - categoryBonusPoints, - finalPoints: totalPoints, - }, - }; - } - - /** - * Earn points for a purchase - */ - async earnPoints( - tenantId: string, - input: EarnPointsInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const membership = await queryRunner.manager.findOne(CustomerMembership, { - where: { id: input.membershipId, tenantId, status: MembershipStatus.ACTIVE }, - }); - - if (!membership) { - return { - success: false, - error: { code: 'MEMBERSHIP_NOT_FOUND', message: 'Active membership not found' }, - }; - } - - const program = await queryRunner.manager.findOne(LoyaltyProgram, { - where: { id: membership.programId, tenantId, status: ProgramStatus.ACTIVE }, - }); - - if (!program) { - return { - success: false, - error: { code: 'PROGRAM_NOT_FOUND', message: 'Active program not found' }, - }; - } - - // Calculate points - const { points, multiplier, breakdown } = await this.calculatePoints( - tenantId, - membership.programId, - membership.id, - input.purchaseAmount, - input.categoryMultipliers - ); - - if (points <= 0) { - return { - success: false, - error: { code: 'NO_POINTS', message: 'No points earned for this purchase' }, - }; - } - - // Update membership - membership.pointsBalance += points; - membership.lifetimePoints += points; - membership.totalPurchases += 1; - membership.totalSpend = Number(membership.totalSpend) + input.purchaseAmount; - membership.averageOrderValue = Number(membership.totalSpend) / membership.totalPurchases; - membership.periodPurchases += 1; - membership.periodSpend = Number(membership.periodSpend) + input.purchaseAmount; - membership.periodPoints += points; - membership.lastPurchaseAt = new Date(); - membership.lastActivityAt = new Date(); - - await queryRunner.manager.save(membership); - - // Create transaction - const txNumber = await this.generateTransactionNumber(tenantId); - const expirationDate = program.pointsExpire - ? new Date(Date.now() + program.expirationMonths * 30 * 24 * 60 * 60 * 1000) - : null; - - const transaction = queryRunner.manager.create(LoyaltyTransaction, { - tenantId, - programId: membership.programId, - membershipId: membership.id, - customerId: membership.customerId, - number: txNumber, - type: TransactionType.EARN, - status: TransactionStatus.COMPLETED, - points, - pointsBalanceAfter: membership.pointsBalance, - purchaseAmount: input.purchaseAmount, - orderId: input.orderId, - orderNumber: input.orderNumber, - branchId: input.branchId, - basePoints: breakdown.basePoints, - multiplierApplied: multiplier, - multiplierReason: multiplier > 1 ? 'Level and/or promotional multiplier' : null, - expiresAt: expirationDate, - description: `Points earned from purchase ${input.orderNumber ?? ''}`, - createdBy: userId, - metadata: breakdown, - }); - - const savedTransaction = await queryRunner.manager.save(transaction); - - // Check for level upgrade - await this.checkLevelUpgrade(queryRunner, tenantId, membership); - - await queryRunner.commitTransaction(); - return { success: true, data: savedTransaction }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'EARN_POINTS_ERROR', - message: error instanceof Error ? error.message : 'Failed to earn points', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Redeem points - */ - async redeemPoints( - tenantId: string, - input: RedeemPointsInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const membership = await queryRunner.manager.findOne(CustomerMembership, { - where: { id: input.membershipId, tenantId, status: MembershipStatus.ACTIVE }, - }); - - if (!membership) { - return { - success: false, - error: { code: 'MEMBERSHIP_NOT_FOUND', message: 'Active membership not found' }, - }; - } - - if (membership.pointsBalance < input.points) { - return { - success: false, - error: { code: 'INSUFFICIENT_POINTS', message: 'Insufficient points balance' }, - }; - } - - const program = await queryRunner.manager.findOne(LoyaltyProgram, { - where: { id: membership.programId, tenantId }, - }); - - if (!program) { - return { - success: false, - error: { code: 'PROGRAM_NOT_FOUND', message: 'Program not found' }, - }; - } - - if (input.points < program.minPointsRedemption) { - return { - success: false, - error: { - code: 'BELOW_MINIMUM', - message: `Minimum redemption is ${program.minPointsRedemption} points`, - }, - }; - } - - // Calculate monetary value - const monetaryValue = input.points * Number(program.pointsValue); - - // Update membership - membership.pointsBalance -= input.points; - membership.pointsRedeemed += input.points; - membership.lastRedemptionAt = new Date(); - membership.lastActivityAt = new Date(); - - await queryRunner.manager.save(membership); - - // Create transaction - const txNumber = await this.generateTransactionNumber(tenantId); - const transaction = queryRunner.manager.create(LoyaltyTransaction, { - tenantId, - programId: membership.programId, - membershipId: membership.id, - customerId: membership.customerId, - number: txNumber, - type: TransactionType.REDEEM, - status: TransactionStatus.COMPLETED, - points: -input.points, - pointsBalanceAfter: membership.pointsBalance, - monetaryValue, - orderId: input.orderId, - orderNumber: input.orderNumber, - branchId: input.branchId, - description: input.description ?? `Points redeemed for ${input.orderNumber ?? 'purchase'}`, - createdBy: userId, - }); - - const savedTransaction = await queryRunner.manager.save(transaction); - - await queryRunner.commitTransaction(); - return { success: true, data: { transaction: savedTransaction, monetaryValue } }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'REDEEM_ERROR', - message: error instanceof Error ? error.message : 'Failed to redeem points', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Adjust points manually - */ - async adjustPoints( - tenantId: string, - input: AdjustPointsInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const membership = await queryRunner.manager.findOne(CustomerMembership, { - where: { id: input.membershipId, tenantId }, - }); - - if (!membership) { - return { - success: false, - error: { code: 'MEMBERSHIP_NOT_FOUND', message: 'Membership not found' }, - }; - } - - const newBalance = membership.pointsBalance + input.points; - if (newBalance < 0) { - return { - success: false, - error: { code: 'NEGATIVE_BALANCE', message: 'Adjustment would result in negative balance' }, - }; - } - - membership.pointsBalance = newBalance; - if (input.points > 0) { - membership.lifetimePoints += input.points; - } - membership.lastActivityAt = new Date(); - - await queryRunner.manager.save(membership); - - const txNumber = await this.generateTransactionNumber(tenantId); - const transaction = queryRunner.manager.create(LoyaltyTransaction, { - tenantId, - programId: membership.programId, - membershipId: membership.id, - customerId: membership.customerId, - number: txNumber, - type: TransactionType.ADJUST, - status: TransactionStatus.COMPLETED, - points: input.points, - pointsBalanceAfter: membership.pointsBalance, - description: input.reason, - createdBy: userId, - }); - - const savedTransaction = await queryRunner.manager.save(transaction); - await queryRunner.commitTransaction(); - - return { success: true, data: savedTransaction }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'ADJUST_ERROR', - message: error instanceof Error ? error.message : 'Failed to adjust points', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Check and apply level upgrade - */ - private async checkLevelUpgrade( - queryRunner: any, - tenantId: string, - membership: CustomerMembership - ): Promise { - const levels = await queryRunner.manager.find(MembershipLevel, { - where: { tenantId, programId: membership.programId, status: LevelStatus.ACTIVE }, - order: { level: 'DESC' }, - }); - - for (const level of levels) { - if (level.id === membership.levelId) continue; - if (level.level <= 0) continue; - - const meetsPointsReq = membership.periodPoints >= level.minPointsRequired; - const meetsPurchasesReq = membership.periodPurchases >= level.minPurchasesRequired; - const meetsSpendReq = Number(membership.periodSpend) >= Number(level.minSpendRequired); - - if (meetsPointsReq && meetsPurchasesReq && meetsSpendReq && level.autoUpgrade) { - membership.previousLevelId = membership.levelId; - membership.levelId = level.id; - membership.levelAchievedAt = new Date(); - - const retentionEndDate = new Date(); - retentionEndDate.setMonth(retentionEndDate.getMonth() + level.retentionMonths); - membership.levelExpiresAt = retentionEndDate; - - await queryRunner.manager.save(membership); - return true; - } - } - - return false; - } - - /** - * Get membership by customer ID - */ - async getMembershipByCustomer( - tenantId: string, - customerId: string, - programId?: string - ): Promise { - const where: any = { tenantId, customerId }; - if (programId) { - where.programId = programId; - } - - return this.membershipRepository.findOne({ where }); - } - - /** - * Get membership by card number - */ - async getMembershipByCard( - tenantId: string, - cardNumber: string - ): Promise { - return this.membershipRepository.findOne({ - where: { tenantId, cardNumber }, - }); - } - - /** - * Get transaction history - */ - async getTransactionHistory( - tenantId: string, - membershipId: string, - options?: { page?: number; limit?: number; type?: TransactionType } - ): Promise<{ data: LoyaltyTransaction[]; total: number }> { - const qb = this.transactionRepository.createQueryBuilder('tx') - .where('tx.tenantId = :tenantId', { tenantId }) - .andWhere('tx.membershipId = :membershipId', { membershipId }); - - if (options?.type) { - qb.andWhere('tx.type = :type', { type: options.type }); - } - - const page = options?.page ?? 1; - const limit = options?.limit ?? 20; - qb.skip((page - 1) * limit).take(limit); - qb.orderBy('tx.createdAt', 'DESC'); - - const [data, total] = await qb.getManyAndCount(); - return { data, total }; - } - - /** - * Find memberships with filters - */ - async findMemberships( - tenantId: string, - options: MembershipQueryOptions - ): Promise<{ data: CustomerMembership[]; total: number }> { - const qb = this.membershipRepository.createQueryBuilder('membership') - .where('membership.tenantId = :tenantId', { tenantId }); - - if (options.programId) { - qb.andWhere('membership.programId = :programId', { programId: options.programId }); - } - if (options.levelId) { - qb.andWhere('membership.levelId = :levelId', { levelId: options.levelId }); - } - if (options.status) { - qb.andWhere('membership.status = :status', { status: options.status }); - } - if (options.customerId) { - qb.andWhere('membership.customerId = :customerId', { customerId: options.customerId }); - } - - const page = options.page ?? 1; - const limit = options.limit ?? 20; - qb.skip((page - 1) * limit).take(limit); - qb.orderBy('membership.enrolledAt', 'DESC'); - - const [data, total] = await qb.getManyAndCount(); - return { data, total }; - } - - /** - * Get points about to expire - */ - async getExpiringPoints( - tenantId: string, - membershipId: string, - daysAhead: number = 30 - ): Promise<{ points: number; expirationDate: Date | null }> { - const futureDate = new Date(); - futureDate.setDate(futureDate.getDate() + daysAhead); - - const expiringTransactions = await this.transactionRepository.find({ - where: { - tenantId, - membershipId, - type: TransactionType.EARN, - expired: false, - expiresAt: LessThanOrEqual(futureDate), - points: MoreThan(0), - }, - order: { expiresAt: 'ASC' }, - }); - - let totalExpiring = 0; - let nearestExpiration: Date | null = null; - - for (const tx of expiringTransactions) { - totalExpiring += tx.points; - if (!nearestExpiration && tx.expiresAt) { - nearestExpiration = tx.expiresAt; - } - } - - return { points: totalExpiring, expirationDate: nearestExpiration }; - } -} diff --git a/backend/src/modules/customers/validation/customers.schema.ts b/backend/src/modules/customers/validation/customers.schema.ts deleted file mode 100644 index 7052b96..0000000 --- a/backend/src/modules/customers/validation/customers.schema.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { z } from 'zod'; -import { - uuidSchema, - moneySchema, - paginationSchema, -} from '../../../shared/validation/common.schema'; - -// Enums -export const programStatusEnum = z.enum(['active', 'inactive', 'suspended']); -export const membershipStatusEnum = z.enum(['active', 'inactive', 'suspended', 'expired']); -export const transactionTypeEnum = z.enum([ - 'earn', - 'redeem', - 'expire', - 'adjust', - 'bonus', - 'transfer_in', - 'transfer_out', - 'refund', -]); - -// ==================== PROGRAM SCHEMAS ==================== - -// Create program schema -export const createProgramSchema = z.object({ - code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'), - name: z.string().min(1).max(100), - description: z.string().max(1000).optional(), - pointsCalculation: z.enum(['fixed_per_purchase', 'percentage_of_amount', 'points_per_currency']).default('points_per_currency'), - pointsPerCurrency: z.number().positive().default(1), - currencyPerPoint: z.number().positive().default(10), - minimumPurchase: moneySchema.default(0), - roundPoints: z.boolean().default(true), - roundDirection: z.enum(['up', 'down', 'nearest']).default('down'), - pointsValue: z.number().positive().default(0.1), - minPointsRedemption: z.number().int().positive().default(100), - maxRedemptionPercent: z.number().min(0).max(100).default(100), - allowPartialRedemption: z.boolean().default(true), - pointsExpire: z.boolean().default(true), - expirationMonths: z.number().int().min(1).max(120).default(12), - welcomeBonus: z.number().int().min(0).default(0), - birthdayBonus: z.number().int().min(0).default(0), - referralBonus: z.number().int().min(0).default(0), - refereeBonus: z.number().int().min(0).default(0), - validFrom: z.coerce.date().optional(), - validUntil: z.coerce.date().optional(), - termsAndConditions: z.string().max(10000).optional(), -}); - -// Update program schema -export const updateProgramSchema = createProgramSchema.partial().omit({ code: true }); - -// ==================== LEVEL SCHEMAS ==================== - -// Create level schema -export const createLevelSchema = z.object({ - programId: uuidSchema, - code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'), - name: z.string().min(1).max(50), - description: z.string().max(500).optional(), - level: z.number().int().min(0).default(0), - minPointsRequired: z.number().int().min(0).default(0), - minPurchasesRequired: z.number().int().min(0).default(0), - minSpendRequired: moneySchema.default(0), - qualificationPeriodMonths: z.number().int().min(1).max(24).default(12), - pointsMultiplier: z.number().min(1).max(10).default(1), - discountPercent: z.number().min(0).max(100).default(0), - freeShipping: z.boolean().default(false), - freeShippingThreshold: moneySchema.optional(), - prioritySupport: z.boolean().default(false), - earlyAccess: z.boolean().default(false), - exclusivePromotions: z.boolean().default(false), - birthdayMultiplier: z.number().min(1).max(10).default(1), - color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid color format').default('#000000'), - retentionMonths: z.number().int().min(1).max(36).default(12), - autoUpgrade: z.boolean().default(true), -}); - -// Update level schema -export const updateLevelSchema = createLevelSchema.partial().omit({ programId: true, code: true }); - -// ==================== MEMBERSHIP SCHEMAS ==================== - -// Enroll customer schema -export const enrollCustomerSchema = z.object({ - customerId: uuidSchema, - programId: uuidSchema, - referredById: uuidSchema.optional(), - emailNotifications: z.boolean().default(true), - smsNotifications: z.boolean().default(false), -}); - -// Update membership schema -export const updateMembershipSchema = z.object({ - status: membershipStatusEnum.optional(), - emailNotifications: z.boolean().optional(), - smsNotifications: z.boolean().optional(), - pushNotifications: z.boolean().optional(), - favoriteBranchId: uuidSchema.optional(), - notes: z.string().max(1000).optional(), -}); - -// List memberships query schema -export const listMembershipsQuerySchema = paginationSchema.extend({ - programId: uuidSchema.optional(), - levelId: uuidSchema.optional(), - status: membershipStatusEnum.optional(), - customerId: uuidSchema.optional(), -}); - -// ==================== TRANSACTION SCHEMAS ==================== - -// Category amount schema (for multipliers) -export const categoryAmountSchema = z.object({ - categoryId: uuidSchema, - amount: moneySchema.positive(), -}); - -// Earn points schema -export const earnPointsSchema = z.object({ - membershipId: uuidSchema, - purchaseAmount: moneySchema.positive('Purchase amount must be positive'), - orderId: uuidSchema.optional(), - orderNumber: z.string().max(30).optional(), - branchId: uuidSchema.optional(), - categoryMultipliers: z.array(categoryAmountSchema).optional(), -}); - -// Redeem points schema -export const redeemPointsSchema = z.object({ - membershipId: uuidSchema, - points: z.number().int().positive('Points must be positive'), - orderId: uuidSchema.optional(), - orderNumber: z.string().max(30).optional(), - branchId: uuidSchema.optional(), - description: z.string().max(255).optional(), -}); - -// Adjust points schema -export const adjustPointsSchema = z.object({ - membershipId: uuidSchema, - points: z.number().int().refine(v => v !== 0, 'Points cannot be zero'), - reason: z.string().min(1, 'Reason is required').max(255), -}); - -// List transactions query schema -export const listTransactionsQuerySchema = paginationSchema.extend({ - membershipId: uuidSchema.optional(), - type: transactionTypeEnum.optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), -}); - -// ==================== LOOKUP SCHEMAS ==================== - -// Lookup by card number -export const lookupByCardSchema = z.object({ - cardNumber: z.string().min(10).max(20), -}); - -// Calculate points preview schema -export const calculatePointsSchema = z.object({ - membershipId: uuidSchema, - purchaseAmount: moneySchema.positive(), - categoryMultipliers: z.array(categoryAmountSchema).optional(), -}); - -// Types -export type CreateProgramInput = z.infer; -export type UpdateProgramInput = z.infer; -export type CreateLevelInput = z.infer; -export type UpdateLevelInput = z.infer; -export type EnrollCustomerInput = z.infer; -export type UpdateMembershipInput = z.infer; -export type EarnPointsInput = z.infer; -export type RedeemPointsInput = z.infer; -export type AdjustPointsInput = z.infer; -export type ListMembershipsQuery = z.infer; -export type ListTransactionsQuery = z.infer; diff --git a/backend/src/modules/ecommerce/entities/cart-item.entity.ts b/backend/src/modules/ecommerce/entities/cart-item.entity.ts deleted file mode 100644 index 300d074..0000000 --- a/backend/src/modules/ecommerce/entities/cart-item.entity.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { Cart } from './cart.entity'; - -@Entity('cart_items', { schema: 'retail' }) -@Index(['tenantId', 'cartId']) -@Index(['tenantId', 'productId']) -@Index(['tenantId', 'cartId', 'productId', 'variantId'], { unique: true }) -export class CartItem { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'cart_id', type: 'uuid' }) - cartId: string; - - // Product (from erp-core) - @Column({ name: 'product_id', type: 'uuid' }) - productId: string; - - @Column({ name: 'product_code', length: 50 }) - productCode: string; - - @Column({ name: 'product_name', length: 200 }) - productName: string; - - @Column({ name: 'product_slug', length: 255, nullable: true }) - productSlug: string; - - @Column({ name: 'product_image', length: 255, nullable: true }) - productImage: string; - - // Variant - @Column({ name: 'variant_id', type: 'uuid', nullable: true }) - variantId: string; - - @Column({ name: 'variant_name', length: 200, nullable: true }) - variantName: string; - - @Column({ name: 'variant_sku', length: 50, nullable: true }) - variantSku: string; - - // Variant options - @Column({ name: 'variant_options', type: 'jsonb', nullable: true }) - variantOptions: { - name: string; - value: string; - }[]; - - // Quantity - @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) - quantity: number; - - // Pricing - @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 }) - unitPrice: number; - - @Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 }) - originalPrice: number; - - @Column({ name: 'price_list_id', type: 'uuid', nullable: true }) - priceListId: string; - - // Discounts - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) - discountPercent: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - discountAmount: number; - - @Column({ name: 'promotion_id', type: 'uuid', nullable: true }) - promotionId: string; - - @Column({ name: 'promotion_name', length: 100, nullable: true }) - promotionName: string; - - // Tax - @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) - taxRate: number; - - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; - - @Column({ name: 'tax_included', type: 'boolean', default: true }) - taxIncluded: boolean; - - // Totals - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - // Stock check - @Column({ name: 'stock_available', type: 'decimal', precision: 15, scale: 4, nullable: true }) - stockAvailable: number; - - @Column({ name: 'is_available', type: 'boolean', default: true }) - isAvailable: boolean; - - @Column({ name: 'availability_message', length: 255, nullable: true }) - availabilityMessage: string; - - // Warehouse - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId: string; - - // Gift options - @Column({ name: 'is_gift', type: 'boolean', default: false }) - isGift: boolean; - - @Column({ name: 'gift_message', type: 'text', nullable: true }) - giftMessage: string; - - // Custom options (personalization, etc.) - @Column({ name: 'custom_options', type: 'jsonb', nullable: true }) - customOptions: { - name: string; - value: string; - price?: number; - }[]; - - // Saved for later - @Column({ name: 'saved_for_later', type: 'boolean', default: false }) - savedForLater: boolean; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - // Relations - @ManyToOne(() => Cart, (cart) => cart.items) - @JoinColumn({ name: 'cart_id' }) - cart: Cart; -} diff --git a/backend/src/modules/ecommerce/entities/cart.entity.ts b/backend/src/modules/ecommerce/entities/cart.entity.ts deleted file mode 100644 index 9efa479..0000000 --- a/backend/src/modules/ecommerce/entities/cart.entity.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { CartItem } from './cart-item.entity'; - -export enum CartStatus { - ACTIVE = 'active', - ABANDONED = 'abandoned', - CONVERTED = 'converted', - MERGED = 'merged', - EXPIRED = 'expired', -} - -@Entity('carts', { schema: 'retail' }) -@Index(['tenantId', 'customerId']) -@Index(['tenantId', 'sessionId']) -@Index(['tenantId', 'status', 'updatedAt']) -export class Cart { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - // Customer (if logged in, from erp-core partners) - @Column({ name: 'customer_id', type: 'uuid', nullable: true }) - customerId: string; - - // Session (for guest carts) - @Column({ name: 'session_id', length: 100, nullable: true }) - sessionId: string; - - @Column({ - type: 'enum', - enum: CartStatus, - default: CartStatus.ACTIVE, - }) - status: CartStatus; - - // Currency - @Column({ name: 'currency_code', length: 3, default: 'MXN' }) - currencyCode: string; - - // Totals - @Column({ name: 'items_count', type: 'int', default: 0 }) - itemsCount: number; - - @Column({ name: 'items_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 }) - itemsQuantity: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - discountAmount: number; - - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; - - @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - shippingAmount: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - // Coupon - @Column({ name: 'coupon_id', type: 'uuid', nullable: true }) - couponId: string; - - @Column({ name: 'coupon_code', length: 50, nullable: true }) - couponCode: string; - - @Column({ name: 'coupon_discount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - couponDiscount: number; - - // Loyalty points - @Column({ name: 'loyalty_points_to_use', type: 'int', default: 0 }) - loyaltyPointsToUse: number; - - @Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - loyaltyPointsValue: number; - - // Shipping - @Column({ name: 'shipping_method_id', type: 'uuid', nullable: true }) - shippingMethodId: string; - - @Column({ name: 'shipping_method_name', length: 100, nullable: true }) - shippingMethodName: string; - - // Shipping address - @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) - shippingAddress: { - firstName: string; - lastName: string; - company?: string; - address1: string; - address2?: string; - city: string; - state: string; - postalCode: string; - country: string; - phone: string; - instructions?: string; - }; - - // Billing address - @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) - billingAddress: { - firstName: string; - lastName: string; - company?: string; - rfc?: string; - address1: string; - address2?: string; - city: string; - state: string; - postalCode: string; - country: string; - phone: string; - }; - - @Column({ name: 'billing_same_as_shipping', type: 'boolean', default: true }) - billingSameAsShipping: boolean; - - // Invoice request - @Column({ name: 'requires_invoice', type: 'boolean', default: false }) - requiresInvoice: boolean; - - @Column({ name: 'invoice_rfc', length: 13, nullable: true }) - invoiceRfc: string; - - @Column({ name: 'invoice_uso_cfdi', length: 4, nullable: true }) - invoiceUsoCfdi: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Gift - @Column({ name: 'is_gift', type: 'boolean', default: false }) - isGift: boolean; - - @Column({ name: 'gift_message', type: 'text', nullable: true }) - giftMessage: string; - - // Abandoned cart recovery - @Column({ name: 'recovery_email_sent', type: 'boolean', default: false }) - recoveryEmailSent: boolean; - - @Column({ name: 'recovery_email_sent_at', type: 'timestamp with time zone', nullable: true }) - recoveryEmailSentAt: Date; - - // Conversion tracking - @Column({ name: 'converted_to_order_id', type: 'uuid', nullable: true }) - convertedToOrderId: string; - - @Column({ name: 'converted_at', type: 'timestamp with time zone', nullable: true }) - convertedAt: Date; - - // Merge tracking - @Column({ name: 'merged_into_cart_id', type: 'uuid', nullable: true }) - mergedIntoCartId: string; - - // Analytics - @Column({ name: 'utm_source', length: 100, nullable: true }) - utmSource: string; - - @Column({ name: 'utm_medium', length: 100, nullable: true }) - utmMedium: string; - - @Column({ name: 'utm_campaign', length: 100, nullable: true }) - utmCampaign: string; - - @Column({ name: 'referrer_url', length: 255, nullable: true }) - referrerUrl: string; - - // Device info - @Column({ name: 'device_type', length: 20, nullable: true }) - deviceType: 'desktop' | 'tablet' | 'mobile'; - - @Column({ name: 'ip_address', length: 45, nullable: true }) - ipAddress: string; - - @Column({ name: 'user_agent', type: 'text', nullable: true }) - userAgent: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'expires_at', type: 'timestamp with time zone', nullable: true }) - expiresAt: Date; - - // Relations - @OneToMany(() => CartItem, (item) => item.cart) - items: CartItem[]; -} diff --git a/backend/src/modules/ecommerce/entities/ecommerce-order-line.entity.ts b/backend/src/modules/ecommerce/entities/ecommerce-order-line.entity.ts deleted file mode 100644 index bf187ce..0000000 --- a/backend/src/modules/ecommerce/entities/ecommerce-order-line.entity.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { EcommerceOrder } from './ecommerce-order.entity'; - -export enum OrderLineStatus { - PENDING = 'pending', - RESERVED = 'reserved', - FULFILLED = 'fulfilled', - SHIPPED = 'shipped', - DELIVERED = 'delivered', - CANCELLED = 'cancelled', - RETURNED = 'returned', - REFUNDED = 'refunded', -} - -@Entity('ecommerce_order_lines', { schema: 'retail' }) -@Index(['tenantId', 'orderId']) -@Index(['tenantId', 'productId']) -export class EcommerceOrderLine { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'order_id', type: 'uuid' }) - orderId: string; - - @Column({ name: 'line_number', type: 'int' }) - lineNumber: number; - - @Column({ - type: 'enum', - enum: OrderLineStatus, - default: OrderLineStatus.PENDING, - }) - status: OrderLineStatus; - - // Product (from erp-core) - @Column({ name: 'product_id', type: 'uuid' }) - productId: string; - - @Column({ name: 'product_code', length: 50 }) - productCode: string; - - @Column({ name: 'product_name', length: 200 }) - productName: string; - - @Column({ name: 'product_slug', length: 255, nullable: true }) - productSlug: string; - - @Column({ name: 'product_image', length: 255, nullable: true }) - productImage: string; - - // Variant - @Column({ name: 'variant_id', type: 'uuid', nullable: true }) - variantId: string; - - @Column({ name: 'variant_name', length: 200, nullable: true }) - variantName: string; - - @Column({ name: 'variant_sku', length: 50, nullable: true }) - variantSku: string; - - @Column({ name: 'variant_options', type: 'jsonb', nullable: true }) - variantOptions: { - name: string; - value: string; - }[]; - - // Quantity - @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) - quantity: number; - - @Column({ name: 'quantity_fulfilled', type: 'decimal', precision: 15, scale: 4, default: 0 }) - quantityFulfilled: number; - - @Column({ name: 'quantity_refunded', type: 'decimal', precision: 15, scale: 4, default: 0 }) - quantityRefunded: number; - - // UOM - @Column({ name: 'uom_id', type: 'uuid', nullable: true }) - uomId: string; - - @Column({ name: 'uom_name', length: 20, default: 'PZA' }) - uomName: string; - - // Pricing - @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 }) - unitPrice: number; - - @Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 }) - originalPrice: number; - - // Discounts - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) - discountPercent: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - discountAmount: number; - - @Column({ name: 'promotion_id', type: 'uuid', nullable: true }) - promotionId: string; - - @Column({ name: 'promotion_name', length: 100, nullable: true }) - promotionName: string; - - // Tax - @Column({ name: 'tax_id', type: 'uuid', nullable: true }) - taxId: string; - - @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) - taxRate: number; - - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; - - @Column({ name: 'tax_included', type: 'boolean', default: true }) - taxIncluded: boolean; - - // Totals - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - // Cost - @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) - unitCost: number; - - // Weight (for shipping calculation) - @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) - weight: number; - - @Column({ name: 'weight_unit', length: 5, default: 'kg' }) - weightUnit: string; - - // Warehouse - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId: string; - - // Lot/Serial - @Column({ name: 'lot_number', length: 50, nullable: true }) - lotNumber: string; - - @Column({ name: 'serial_number', length: 50, nullable: true }) - serialNumber: string; - - // Custom options - @Column({ name: 'custom_options', type: 'jsonb', nullable: true }) - customOptions: { - name: string; - value: string; - price?: number; - }[]; - - // Gift - @Column({ name: 'is_gift', type: 'boolean', default: false }) - isGift: boolean; - - @Column({ name: 'gift_message', type: 'text', nullable: true }) - giftMessage: string; - - // Refund - @Column({ name: 'refunded_at', type: 'timestamp with time zone', nullable: true }) - refundedAt: Date; - - @Column({ name: 'refund_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - refundAmount: number; - - @Column({ name: 'refund_reason', length: 255, nullable: true }) - refundReason: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - // Relations - @ManyToOne(() => EcommerceOrder, (order) => order.lines) - @JoinColumn({ name: 'order_id' }) - order: EcommerceOrder; -} diff --git a/backend/src/modules/ecommerce/entities/ecommerce-order.entity.ts b/backend/src/modules/ecommerce/entities/ecommerce-order.entity.ts deleted file mode 100644 index 21fb510..0000000 --- a/backend/src/modules/ecommerce/entities/ecommerce-order.entity.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { EcommerceOrderLine } from './ecommerce-order-line.entity'; - -export enum EcommerceOrderStatus { - PENDING = 'pending', - CONFIRMED = 'confirmed', - PROCESSING = 'processing', - READY_FOR_PICKUP = 'ready_for_pickup', - SHIPPED = 'shipped', - DELIVERED = 'delivered', - CANCELLED = 'cancelled', - REFUNDED = 'refunded', -} - -export enum PaymentStatus { - PENDING = 'pending', - AUTHORIZED = 'authorized', - CAPTURED = 'captured', - FAILED = 'failed', - REFUNDED = 'refunded', - PARTIALLY_REFUNDED = 'partially_refunded', -} - -export enum FulfillmentType { - SHIP = 'ship', - PICKUP = 'pickup', - DELIVERY = 'delivery', -} - -@Entity('ecommerce_orders', { schema: 'retail' }) -@Index(['tenantId', 'status', 'createdAt']) -@Index(['tenantId', 'customerId']) -@Index(['tenantId', 'number'], { unique: true }) -@Index(['tenantId', 'paymentStatus']) -export class EcommerceOrder { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ length: 30, unique: true }) - number: string; - - @Column({ - type: 'enum', - enum: EcommerceOrderStatus, - default: EcommerceOrderStatus.PENDING, - }) - status: EcommerceOrderStatus; - - @Column({ - name: 'payment_status', - type: 'enum', - enum: PaymentStatus, - default: PaymentStatus.PENDING, - }) - paymentStatus: PaymentStatus; - - @Column({ - name: 'fulfillment_type', - type: 'enum', - enum: FulfillmentType, - default: FulfillmentType.SHIP, - }) - fulfillmentType: FulfillmentType; - - // Customer - @Column({ name: 'customer_id', type: 'uuid', nullable: true }) - customerId: string; - - @Column({ name: 'customer_email', length: 100 }) - customerEmail: string; - - @Column({ name: 'customer_phone', length: 20, nullable: true }) - customerPhone: string; - - @Column({ name: 'customer_name', length: 200 }) - customerName: string; - - @Column({ name: 'is_guest', type: 'boolean', default: false }) - isGuest: boolean; - - // Currency - @Column({ name: 'currency_code', length: 3, default: 'MXN' }) - currencyCode: string; - - @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) - exchangeRate: number; - - // Amounts - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - discountAmount: number; - - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; - - @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - shippingAmount: number; - - @Column({ name: 'shipping_tax', type: 'decimal', precision: 15, scale: 2, default: 0 }) - shippingTax: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - @Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 }) - amountPaid: number; - - @Column({ name: 'amount_refunded', type: 'decimal', precision: 15, scale: 2, default: 0 }) - amountRefunded: number; - - // Item counts - @Column({ name: 'items_count', type: 'int', default: 0 }) - itemsCount: number; - - @Column({ name: 'items_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 }) - itemsQuantity: number; - - // Coupon - @Column({ name: 'coupon_id', type: 'uuid', nullable: true }) - couponId: string; - - @Column({ name: 'coupon_code', length: 50, nullable: true }) - couponCode: string; - - @Column({ name: 'coupon_discount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - couponDiscount: number; - - // Loyalty - @Column({ name: 'loyalty_points_used', type: 'int', default: 0 }) - loyaltyPointsUsed: number; - - @Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - loyaltyPointsValue: number; - - @Column({ name: 'loyalty_points_earned', type: 'int', default: 0 }) - loyaltyPointsEarned: number; - - // Shipping address - @Column({ name: 'shipping_address', type: 'jsonb' }) - shippingAddress: { - firstName: string; - lastName: string; - company?: string; - address1: string; - address2?: string; - city: string; - state: string; - postalCode: string; - country: string; - phone: string; - instructions?: string; - }; - - // Billing address - @Column({ name: 'billing_address', type: 'jsonb' }) - billingAddress: { - firstName: string; - lastName: string; - company?: string; - rfc?: string; - address1: string; - address2?: string; - city: string; - state: string; - postalCode: string; - country: string; - phone: string; - }; - - // Shipping - @Column({ name: 'shipping_method_id', type: 'uuid', nullable: true }) - shippingMethodId: string; - - @Column({ name: 'shipping_method_name', length: 100, nullable: true }) - shippingMethodName: string; - - @Column({ name: 'shipping_carrier', length: 100, nullable: true }) - shippingCarrier: string; - - @Column({ name: 'tracking_number', length: 100, nullable: true }) - trackingNumber: string; - - @Column({ name: 'tracking_url', length: 255, nullable: true }) - trackingUrl: string; - - @Column({ name: 'estimated_delivery', type: 'date', nullable: true }) - estimatedDelivery: Date; - - @Column({ name: 'shipped_at', type: 'timestamp with time zone', nullable: true }) - shippedAt: Date; - - @Column({ name: 'delivered_at', type: 'timestamp with time zone', nullable: true }) - deliveredAt: Date; - - // Pickup - @Column({ name: 'pickup_branch_id', type: 'uuid', nullable: true }) - pickupBranchId: string; - - @Column({ name: 'pickup_ready_at', type: 'timestamp with time zone', nullable: true }) - pickupReadyAt: Date; - - @Column({ name: 'picked_up_at', type: 'timestamp with time zone', nullable: true }) - pickedUpAt: Date; - - // Payment - @Column({ name: 'payment_method', length: 50, nullable: true }) - paymentMethod: string; - - @Column({ name: 'payment_gateway', length: 50, nullable: true }) - paymentGateway: string; - - @Column({ name: 'payment_transaction_id', length: 100, nullable: true }) - paymentTransactionId: string; - - @Column({ name: 'payment_details', type: 'jsonb', nullable: true }) - paymentDetails: Record; - - // Invoice - @Column({ name: 'requires_invoice', type: 'boolean', default: false }) - requiresInvoice: boolean; - - @Column({ name: 'invoice_rfc', length: 13, nullable: true }) - invoiceRfc: string; - - @Column({ name: 'invoice_uso_cfdi', length: 4, nullable: true }) - invoiceUsoCfdi: string; - - @Column({ name: 'cfdi_id', type: 'uuid', nullable: true }) - cfdiId: string; - - @Column({ name: 'invoice_status', length: 20, nullable: true }) - invoiceStatus: string; - - // Gift - @Column({ name: 'is_gift', type: 'boolean', default: false }) - isGift: boolean; - - @Column({ name: 'gift_message', type: 'text', nullable: true }) - giftMessage: string; - - // Notes - @Column({ name: 'customer_notes', type: 'text', nullable: true }) - customerNotes: string; - - @Column({ name: 'internal_notes', type: 'text', nullable: true }) - internalNotes: string; - - // Cancellation - @Column({ name: 'cancelled_at', type: 'timestamp with time zone', nullable: true }) - cancelledAt: Date; - - @Column({ name: 'cancellation_reason', length: 255, nullable: true }) - cancellationReason: string; - - // Source cart - @Column({ name: 'cart_id', type: 'uuid', nullable: true }) - cartId: string; - - // Fulfillment warehouse - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId: string; - - // Analytics - @Column({ name: 'utm_source', length: 100, nullable: true }) - utmSource: string; - - @Column({ name: 'utm_medium', length: 100, nullable: true }) - utmMedium: string; - - @Column({ name: 'utm_campaign', length: 100, nullable: true }) - utmCampaign: string; - - @Column({ name: 'device_type', length: 20, nullable: true }) - deviceType: string; - - @Column({ name: 'ip_address', length: 45, nullable: true }) - ipAddress: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'confirmed_at', type: 'timestamp with time zone', nullable: true }) - confirmedAt: Date; - - // Relations - @OneToMany(() => EcommerceOrderLine, (line) => line.order) - lines: EcommerceOrderLine[]; -} diff --git a/backend/src/modules/ecommerce/entities/index.ts b/backend/src/modules/ecommerce/entities/index.ts deleted file mode 100644 index a61a16e..0000000 --- a/backend/src/modules/ecommerce/entities/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './cart.entity'; -export * from './cart-item.entity'; -export * from './ecommerce-order.entity'; -export * from './ecommerce-order-line.entity'; -export * from './shipping-rate.entity'; diff --git a/backend/src/modules/ecommerce/entities/shipping-rate.entity.ts b/backend/src/modules/ecommerce/entities/shipping-rate.entity.ts deleted file mode 100644 index 597e2b5..0000000 --- a/backend/src/modules/ecommerce/entities/shipping-rate.entity.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index, -} from 'typeorm'; - -export enum ShippingRateStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', -} - -export enum ShippingCalculation { - FLAT_RATE = 'flat_rate', - BY_WEIGHT = 'by_weight', - BY_PRICE = 'by_price', - BY_QUANTITY = 'by_quantity', - FREE = 'free', - CARRIER_CALCULATED = 'carrier_calculated', -} - -@Entity('shipping_rates', { schema: 'retail' }) -@Index(['tenantId', 'status']) -@Index(['tenantId', 'code'], { unique: true }) -export class ShippingRate { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ length: 20, unique: true }) - code: string; - - @Column({ length: 100 }) - name: string; - - @Column({ type: 'text', nullable: true }) - description: string; - - @Column({ - type: 'enum', - enum: ShippingRateStatus, - default: ShippingRateStatus.ACTIVE, - }) - status: ShippingRateStatus; - - @Column({ - name: 'calculation_type', - type: 'enum', - enum: ShippingCalculation, - default: ShippingCalculation.FLAT_RATE, - }) - calculationType: ShippingCalculation; - - // Flat rate - @Column({ name: 'flat_rate', type: 'decimal', precision: 15, scale: 2, nullable: true }) - flatRate: number; - - // Weight-based rates - @Column({ name: 'weight_rates', type: 'jsonb', nullable: true }) - weightRates: { - minWeight: number; - maxWeight: number; - rate: number; - }[]; - - @Column({ name: 'weight_unit', length: 5, default: 'kg' }) - weightUnit: string; - - // Price-based rates - @Column({ name: 'price_rates', type: 'jsonb', nullable: true }) - priceRates: { - minPrice: number; - maxPrice: number; - rate: number; - }[]; - - // Quantity-based rates - @Column({ name: 'quantity_rates', type: 'jsonb', nullable: true }) - quantityRates: { - minQuantity: number; - maxQuantity: number; - rate: number; - }[]; - - // Free shipping threshold - @Column({ name: 'free_shipping_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true }) - freeShippingThreshold: number; - - // Carrier integration - @Column({ name: 'carrier_code', length: 50, nullable: true }) - carrierCode: string; - - @Column({ name: 'carrier_name', length: 100, nullable: true }) - carrierName: string; - - @Column({ name: 'carrier_service', length: 50, nullable: true }) - carrierService: string; - - @Column({ name: 'carrier_api_config', type: 'jsonb', nullable: true }) - carrierApiConfig: Record; - - // Delivery time - @Column({ name: 'min_delivery_days', type: 'int', nullable: true }) - minDeliveryDays: number; - - @Column({ name: 'max_delivery_days', type: 'int', nullable: true }) - maxDeliveryDays: number; - - @Column({ name: 'delivery_message', length: 255, nullable: true }) - deliveryMessage: string; - - // Geographic restrictions - @Column({ name: 'allowed_countries', type: 'jsonb', nullable: true }) - allowedCountries: string[]; - - @Column({ name: 'allowed_states', type: 'jsonb', nullable: true }) - allowedStates: string[]; - - @Column({ name: 'allowed_postal_codes', type: 'jsonb', nullable: true }) - allowedPostalCodes: string[]; - - @Column({ name: 'excluded_postal_codes', type: 'jsonb', nullable: true }) - excludedPostalCodes: string[]; - - // Product restrictions - @Column({ name: 'excluded_categories', type: 'jsonb', nullable: true }) - excludedCategories: string[]; - - @Column({ name: 'excluded_products', type: 'jsonb', nullable: true }) - excludedProducts: string[]; - - // Weight/dimension limits - @Column({ name: 'max_weight', type: 'decimal', precision: 10, scale: 4, nullable: true }) - maxWeight: number; - - @Column({ name: 'max_dimensions', type: 'jsonb', nullable: true }) - maxDimensions: { - length: number; - width: number; - height: number; - unit: string; - }; - - // Customer restrictions - @Column({ name: 'customer_levels', type: 'jsonb', nullable: true }) - customerLevels: string[]; - - // Tax - @Column({ name: 'is_taxable', type: 'boolean', default: true }) - isTaxable: boolean; - - @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, nullable: true }) - taxRate: number; - - // Display - @Column({ type: 'int', default: 0 }) - priority: number; - - @Column({ name: 'display_name', length: 100, nullable: true }) - displayName: string; - - @Column({ name: 'icon_url', length: 255, nullable: true }) - iconUrl: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; -} diff --git a/backend/src/modules/inventory/controllers/inventory.controller.ts b/backend/src/modules/inventory/controllers/inventory.controller.ts deleted file mode 100644 index 9007143..0000000 --- a/backend/src/modules/inventory/controllers/inventory.controller.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { Request, Response } from 'express'; -import { BaseController } from '../../../shared/controllers/base.controller'; -import { StockTransferService, TransferQueryOptions } from '../services/stock-transfer.service'; -import { StockAdjustmentService, AdjustmentQueryOptions } from '../services/stock-adjustment.service'; - -export class InventoryController extends BaseController { - constructor( - private readonly transferService: StockTransferService, - private readonly adjustmentService: StockAdjustmentService - ) { - super(); - } - - // ==================== TRANSFER ENDPOINTS ==================== - - /** - * Create a stock transfer - * POST /inventory/transfers - */ - createTransfer = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - - const result = await this.transferService.createTransfer(tenantId, req.body, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.created(res, result.data); - }; - - /** - * List transfers - * GET /inventory/transfers - */ - listTransfers = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const options: TransferQueryOptions = { - page: Number(req.query.page) || 1, - limit: Number(req.query.limit) || 20, - branchId: req.query.branchId as string, - sourceBranchId: req.query.sourceBranchId as string, - destBranchId: req.query.destBranchId as string, - status: req.query.status as any, - type: req.query.type as any, - startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, - endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, - }; - - const result = await this.transferService.findTransfers(tenantId, options); - this.paginated(res, result.data, result.total, options.page!, options.limit!); - }; - - /** - * Get transfer by ID - * GET /inventory/transfers/:id - */ - getTransfer = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const { id } = req.params; - - const transfer = await this.transferService.getTransferWithLines(tenantId, id); - - if (!transfer) { - this.notFound(res, 'Transfer'); - return; - } - - this.success(res, transfer); - }; - - /** - * Submit transfer for approval - * POST /inventory/transfers/:id/submit - */ - submitTransfer = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - - const result = await this.transferService.submitForApproval(tenantId, id, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Transfer submitted for approval'); - }; - - /** - * Approve transfer - * POST /inventory/transfers/:id/approve - */ - approveTransfer = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - - const result = await this.transferService.approveTransfer(tenantId, id, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Transfer approved'); - }; - - /** - * Ship transfer - * POST /inventory/transfers/:id/ship - */ - shipTransfer = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { lines, shippingMethod, trackingNumber, carrierName } = req.body; - - const result = await this.transferService.shipTransfer( - tenantId, - id, - lines, - { method: shippingMethod, trackingNumber, carrierName }, - userId - ); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Transfer shipped'); - }; - - /** - * Receive transfer - * POST /inventory/transfers/:id/receive - */ - receiveTransfer = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { lines, notes } = req.body; - - const result = await this.transferService.receiveTransfer(tenantId, id, lines, notes, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Transfer received'); - }; - - /** - * Cancel transfer - * POST /inventory/transfers/:id/cancel - */ - cancelTransfer = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { reason } = req.body; - - const result = await this.transferService.cancelTransfer(tenantId, id, reason, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Transfer cancelled'); - }; - - /** - * Get pending incoming transfers - * GET /inventory/transfers/incoming - */ - getIncomingTransfers = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const branchId = req.branchContext?.branchId || (req.query.branchId as string); - - if (!branchId) { - this.error(res, 'Branch ID is required', 400); - return; - } - - const transfers = await this.transferService.getPendingIncomingTransfers(tenantId, branchId); - this.success(res, transfers); - }; - - /** - * Get transfer summary - * GET /inventory/transfers/summary - */ - getTransferSummary = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const branchId = req.query.branchId as string; - - const summary = await this.transferService.getTransferSummary(tenantId, branchId); - this.success(res, summary); - }; - - // ==================== ADJUSTMENT ENDPOINTS ==================== - - /** - * Create a stock adjustment - * POST /inventory/adjustments - */ - createAdjustment = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - - const result = await this.adjustmentService.createAdjustment(tenantId, req.body, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.created(res, result.data); - }; - - /** - * List adjustments - * GET /inventory/adjustments - */ - listAdjustments = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const options: AdjustmentQueryOptions = { - page: Number(req.query.page) || 1, - limit: Number(req.query.limit) || 20, - branchId: req.query.branchId as string, - warehouseId: req.query.warehouseId as string, - status: req.query.status as any, - type: req.query.type as any, - startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, - endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, - }; - - const result = await this.adjustmentService.findAdjustments(tenantId, options); - this.paginated(res, result.data, result.total, options.page!, options.limit!); - }; - - /** - * Get adjustment by ID - * GET /inventory/adjustments/:id - */ - getAdjustment = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const { id } = req.params; - - const adjustment = await this.adjustmentService.getAdjustmentWithLines(tenantId, id); - - if (!adjustment) { - this.notFound(res, 'Adjustment'); - return; - } - - this.success(res, adjustment); - }; - - /** - * Submit adjustment for approval - * POST /inventory/adjustments/:id/submit - */ - submitAdjustment = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - - const result = await this.adjustmentService.submitForApproval(tenantId, id, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Adjustment submitted'); - }; - - /** - * Approve adjustment - * POST /inventory/adjustments/:id/approve - */ - approveAdjustment = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - - const result = await this.adjustmentService.approveAdjustment(tenantId, id, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Adjustment approved'); - }; - - /** - * Reject adjustment - * POST /inventory/adjustments/:id/reject - */ - rejectAdjustment = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { reason } = req.body; - - const result = await this.adjustmentService.rejectAdjustment(tenantId, id, userId, reason); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Adjustment rejected'); - }; - - /** - * Post adjustment - * POST /inventory/adjustments/:id/post - */ - postAdjustment = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - - const result = await this.adjustmentService.postAdjustment(tenantId, id, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Adjustment posted to inventory'); - }; - - /** - * Cancel adjustment - * POST /inventory/adjustments/:id/cancel - */ - cancelAdjustment = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - const { reason } = req.body; - - const result = await this.adjustmentService.cancelAdjustment(tenantId, id, reason, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.success(res, result.data, 'Adjustment cancelled'); - }; - - /** - * Add line to adjustment - * POST /inventory/adjustments/:id/lines - */ - addAdjustmentLine = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const userId = req.userContext!.userId; - const { id } = req.params; - - const result = await this.adjustmentService.addLine(tenantId, id, req.body, userId); - - if (!result.success) { - this.error(res, result.error!.message, 400, result.error!.code); - return; - } - - this.created(res, result.data); - }; - - /** - * Get adjustment summary - * GET /inventory/adjustments/summary - */ - getAdjustmentSummary = async (req: Request, res: Response): Promise => { - const tenantId = req.tenantContext!.tenantId; - const branchId = req.query.branchId as string; - const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; - const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; - - const summary = await this.adjustmentService.getAdjustmentSummary( - tenantId, - branchId, - startDate, - endDate - ); - - this.success(res, summary); - }; -} diff --git a/backend/src/modules/inventory/entities/index.ts b/backend/src/modules/inventory/entities/index.ts deleted file mode 100644 index 8ce403e..0000000 --- a/backend/src/modules/inventory/entities/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './stock-transfer.entity'; -export * from './stock-transfer-line.entity'; -export * from './stock-adjustment.entity'; -export * from './stock-adjustment-line.entity'; diff --git a/backend/src/modules/inventory/entities/stock-adjustment-line.entity.ts b/backend/src/modules/inventory/entities/stock-adjustment-line.entity.ts deleted file mode 100644 index f4319b8..0000000 --- a/backend/src/modules/inventory/entities/stock-adjustment-line.entity.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { StockAdjustment } from './stock-adjustment.entity'; - -export enum AdjustmentDirection { - INCREASE = 'increase', - DECREASE = 'decrease', -} - -@Entity('stock_adjustment_lines', { schema: 'retail' }) -@Index(['tenantId', 'adjustmentId']) -@Index(['tenantId', 'productId']) -export class StockAdjustmentLine { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'adjustment_id', type: 'uuid' }) - adjustmentId: string; - - @Column({ name: 'line_number', type: 'int' }) - lineNumber: number; - - // Product (from erp-core) - @Column({ name: 'product_id', type: 'uuid' }) - productId: string; - - @Column({ name: 'product_code', length: 50 }) - productCode: string; - - @Column({ name: 'product_name', length: 200 }) - productName: string; - - @Column({ name: 'product_barcode', length: 50, nullable: true }) - productBarcode: string; - - // Variant - @Column({ name: 'variant_id', type: 'uuid', nullable: true }) - variantId: string; - - @Column({ name: 'variant_name', length: 200, nullable: true }) - variantName: string; - - // Unit of measure - @Column({ name: 'uom_id', type: 'uuid', nullable: true }) - uomId: string; - - @Column({ name: 'uom_name', length: 20, default: 'PZA' }) - uomName: string; - - // Stock quantities - @Column({ name: 'system_quantity', type: 'decimal', precision: 15, scale: 4 }) - systemQuantity: number; - - @Column({ name: 'counted_quantity', type: 'decimal', precision: 15, scale: 4 }) - countedQuantity: number; - - @Column({ name: 'adjustment_quantity', type: 'decimal', precision: 15, scale: 4 }) - adjustmentQuantity: number; - - @Column({ - type: 'enum', - enum: AdjustmentDirection, - }) - direction: AdjustmentDirection; - - // Costs - @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 }) - unitCost: number; - - @Column({ name: 'adjustment_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - adjustmentValue: number; - - // Location - @Column({ name: 'location_id', type: 'uuid', nullable: true }) - locationId: string; - - @Column({ name: 'location_code', length: 50, nullable: true }) - locationCode: string; - - // Lot/Serial tracking - @Column({ name: 'lot_number', length: 50, nullable: true }) - lotNumber: string; - - @Column({ name: 'serial_number', length: 50, nullable: true }) - serialNumber: string; - - @Column({ name: 'expiry_date', type: 'date', nullable: true }) - expiryDate: Date; - - // Reason (line-level) - @Column({ name: 'line_reason', length: 255, nullable: true }) - lineReason: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - // Relations - @ManyToOne(() => StockAdjustment, (adjustment) => adjustment.lines) - @JoinColumn({ name: 'adjustment_id' }) - adjustment: StockAdjustment; -} diff --git a/backend/src/modules/inventory/entities/stock-adjustment.entity.ts b/backend/src/modules/inventory/entities/stock-adjustment.entity.ts deleted file mode 100644 index 7ff62d5..0000000 --- a/backend/src/modules/inventory/entities/stock-adjustment.entity.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { StockAdjustmentLine } from './stock-adjustment-line.entity'; - -export enum AdjustmentStatus { - DRAFT = 'draft', - PENDING_APPROVAL = 'pending_approval', - APPROVED = 'approved', - POSTED = 'posted', - REJECTED = 'rejected', - CANCELLED = 'cancelled', -} - -export enum AdjustmentType { - INVENTORY_COUNT = 'inventory_count', - DAMAGE = 'damage', - THEFT = 'theft', - EXPIRY = 'expiry', - CORRECTION = 'correction', - INITIAL_STOCK = 'initial_stock', - PRODUCTION = 'production', - OTHER = 'other', -} - -@Entity('stock_adjustments', { schema: 'retail' }) -@Index(['tenantId', 'branchId', 'status']) -@Index(['tenantId', 'adjustmentDate']) -@Index(['tenantId', 'number'], { unique: true }) -export class StockAdjustment { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - @Column({ name: 'warehouse_id', type: 'uuid' }) - warehouseId: string; - - @Column({ name: 'location_id', type: 'uuid', nullable: true }) - locationId: string; - - @Column({ length: 30, unique: true }) - number: string; - - @Column({ - type: 'enum', - enum: AdjustmentType, - default: AdjustmentType.INVENTORY_COUNT, - }) - type: AdjustmentType; - - @Column({ - type: 'enum', - enum: AdjustmentStatus, - default: AdjustmentStatus.DRAFT, - }) - status: AdjustmentStatus; - - @Column({ name: 'adjustment_date', type: 'date' }) - adjustmentDate: Date; - - // Count info (for inventory counts) - @Column({ name: 'is_full_count', type: 'boolean', default: false }) - isFullCount: boolean; - - @Column({ name: 'count_category_id', type: 'uuid', nullable: true }) - countCategoryId: string; - - // Summary - @Column({ name: 'lines_count', type: 'int', default: 0 }) - linesCount: number; - - @Column({ name: 'total_increase_qty', type: 'decimal', precision: 15, scale: 4, default: 0 }) - totalIncreaseQty: number; - - @Column({ name: 'total_decrease_qty', type: 'decimal', precision: 15, scale: 4, default: 0 }) - totalDecreaseQty: number; - - @Column({ name: 'total_increase_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalIncreaseValue: number; - - @Column({ name: 'total_decrease_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalDecreaseValue: number; - - @Column({ name: 'net_adjustment_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - netAdjustmentValue: number; - - // Financial account (for accounting integration) - @Column({ name: 'account_id', type: 'uuid', nullable: true }) - accountId: string; - - @Column({ name: 'counterpart_account_id', type: 'uuid', nullable: true }) - counterpartAccountId: string; - - // Approval workflow - @Column({ name: 'requires_approval', type: 'boolean', default: true }) - requiresApproval: boolean; - - @Column({ name: 'approval_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true }) - approvalThreshold: number; - - // Users - @Column({ name: 'created_by', type: 'uuid' }) - createdBy: string; - - @Column({ name: 'approved_by', type: 'uuid', nullable: true }) - approvedBy: string; - - @Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true }) - approvedAt: Date; - - @Column({ name: 'posted_by', type: 'uuid', nullable: true }) - postedBy: string; - - @Column({ name: 'posted_at', type: 'timestamp with time zone', nullable: true }) - postedAt: Date; - - @Column({ name: 'rejected_by', type: 'uuid', nullable: true }) - rejectedBy: string; - - @Column({ name: 'rejected_at', type: 'timestamp with time zone', nullable: true }) - rejectedAt: Date; - - @Column({ name: 'rejection_reason', length: 255, nullable: true }) - rejectionReason: string; - - // Reason/Description - @Column({ type: 'text' }) - reason: string; - - // Reference - @Column({ name: 'reference_type', length: 50, nullable: true }) - referenceType: string; - - @Column({ name: 'reference_id', type: 'uuid', nullable: true }) - referenceId: string; - - @Column({ name: 'reference_number', length: 50, nullable: true }) - referenceNumber: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - // Relations - @OneToMany(() => StockAdjustmentLine, (line) => line.adjustment) - lines: StockAdjustmentLine[]; -} diff --git a/backend/src/modules/inventory/entities/stock-transfer-line.entity.ts b/backend/src/modules/inventory/entities/stock-transfer-line.entity.ts deleted file mode 100644 index 5b313d2..0000000 --- a/backend/src/modules/inventory/entities/stock-transfer-line.entity.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { StockTransfer } from './stock-transfer.entity'; - -export enum TransferLineStatus { - PENDING = 'pending', - PARTIALLY_SHIPPED = 'partially_shipped', - SHIPPED = 'shipped', - PARTIALLY_RECEIVED = 'partially_received', - RECEIVED = 'received', - CANCELLED = 'cancelled', -} - -@Entity('stock_transfer_lines', { schema: 'retail' }) -@Index(['tenantId', 'transferId']) -@Index(['tenantId', 'productId']) -export class StockTransferLine { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'transfer_id', type: 'uuid' }) - transferId: string; - - @Column({ name: 'line_number', type: 'int' }) - lineNumber: number; - - // Product (from erp-core) - @Column({ name: 'product_id', type: 'uuid' }) - productId: string; - - @Column({ name: 'product_code', length: 50 }) - productCode: string; - - @Column({ name: 'product_name', length: 200 }) - productName: string; - - @Column({ name: 'product_barcode', length: 50, nullable: true }) - productBarcode: string; - - // Variant - @Column({ name: 'variant_id', type: 'uuid', nullable: true }) - variantId: string; - - @Column({ name: 'variant_name', length: 200, nullable: true }) - variantName: string; - - // Unit of measure - @Column({ name: 'uom_id', type: 'uuid', nullable: true }) - uomId: string; - - @Column({ name: 'uom_name', length: 20, default: 'PZA' }) - uomName: string; - - // Quantities - @Column({ name: 'quantity_requested', type: 'decimal', precision: 15, scale: 4 }) - quantityRequested: number; - - @Column({ name: 'quantity_approved', type: 'decimal', precision: 15, scale: 4, nullable: true }) - quantityApproved: number; - - @Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 }) - quantityShipped: number; - - @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) - quantityReceived: number; - - @Column({ name: 'quantity_difference', type: 'decimal', precision: 15, scale: 4, default: 0 }) - quantityDifference: number; - - // Costs - @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 }) - unitCost: number; - - @Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalCost: number; - - // Stock info at time of request - @Column({ name: 'source_stock_before', type: 'decimal', precision: 15, scale: 4, nullable: true }) - sourceStockBefore: number; - - @Column({ name: 'dest_stock_before', type: 'decimal', precision: 15, scale: 4, nullable: true }) - destStockBefore: number; - - // Lot/Serial tracking - @Column({ name: 'lot_number', length: 50, nullable: true }) - lotNumber: string; - - @Column({ name: 'serial_numbers', type: 'jsonb', nullable: true }) - serialNumbers: string[]; - - @Column({ name: 'expiry_date', type: 'date', nullable: true }) - expiryDate: Date; - - // Status - @Column({ - type: 'enum', - enum: TransferLineStatus, - default: TransferLineStatus.PENDING, - }) - status: TransferLineStatus; - - // Receiving details - @Column({ name: 'received_date', type: 'timestamp with time zone', nullable: true }) - receivedDate: Date; - - @Column({ name: 'received_by', type: 'uuid', nullable: true }) - receivedBy: string; - - @Column({ name: 'receiving_notes', length: 255, nullable: true }) - receivingNotes: string; - - @Column({ name: 'damage_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 }) - damageQuantity: number; - - @Column({ name: 'damage_reason', length: 255, nullable: true }) - damageReason: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - // Relations - @ManyToOne(() => StockTransfer, (transfer) => transfer.lines) - @JoinColumn({ name: 'transfer_id' }) - transfer: StockTransfer; -} diff --git a/backend/src/modules/inventory/entities/stock-transfer.entity.ts b/backend/src/modules/inventory/entities/stock-transfer.entity.ts deleted file mode 100644 index dbaf3d7..0000000 --- a/backend/src/modules/inventory/entities/stock-transfer.entity.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { StockTransferLine } from './stock-transfer-line.entity'; - -export enum TransferStatus { - DRAFT = 'draft', - PENDING_APPROVAL = 'pending_approval', - APPROVED = 'approved', - IN_TRANSIT = 'in_transit', - PARTIALLY_RECEIVED = 'partially_received', - RECEIVED = 'received', - CANCELLED = 'cancelled', -} - -export enum TransferType { - BRANCH_TO_BRANCH = 'branch_to_branch', - WAREHOUSE_TO_BRANCH = 'warehouse_to_branch', - BRANCH_TO_WAREHOUSE = 'branch_to_warehouse', - INTERNAL = 'internal', -} - -@Entity('stock_transfers', { schema: 'retail' }) -@Index(['tenantId', 'status', 'createdAt']) -@Index(['tenantId', 'sourceBranchId']) -@Index(['tenantId', 'destBranchId']) -@Index(['tenantId', 'number'], { unique: true }) -export class StockTransfer { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ length: 30, unique: true }) - number: string; - - @Column({ - type: 'enum', - enum: TransferType, - default: TransferType.BRANCH_TO_BRANCH, - }) - type: TransferType; - - @Column({ - type: 'enum', - enum: TransferStatus, - default: TransferStatus.DRAFT, - }) - status: TransferStatus; - - // Source - @Column({ name: 'source_branch_id', type: 'uuid', nullable: true }) - sourceBranchId: string; - - @Column({ name: 'source_warehouse_id', type: 'uuid' }) - sourceWarehouseId: string; - - @Column({ name: 'source_location_id', type: 'uuid', nullable: true }) - sourceLocationId: string; - - // Destination - @Column({ name: 'dest_branch_id', type: 'uuid', nullable: true }) - destBranchId: string; - - @Column({ name: 'dest_warehouse_id', type: 'uuid' }) - destWarehouseId: string; - - @Column({ name: 'dest_location_id', type: 'uuid', nullable: true }) - destLocationId: string; - - // Dates - @Column({ name: 'requested_date', type: 'date' }) - requestedDate: Date; - - @Column({ name: 'expected_date', type: 'date', nullable: true }) - expectedDate: Date; - - @Column({ name: 'shipped_date', type: 'timestamp with time zone', nullable: true }) - shippedDate: Date; - - @Column({ name: 'received_date', type: 'timestamp with time zone', nullable: true }) - receivedDate: Date; - - // Line counts - @Column({ name: 'lines_count', type: 'int', default: 0 }) - linesCount: number; - - @Column({ name: 'items_requested', type: 'decimal', precision: 15, scale: 4, default: 0 }) - itemsRequested: number; - - @Column({ name: 'items_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 }) - itemsShipped: number; - - @Column({ name: 'items_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) - itemsReceived: number; - - // Totals (for reporting) - @Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalCost: number; - - @Column({ name: 'total_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalValue: number; - - // Shipping info - @Column({ name: 'shipping_method', length: 50, nullable: true }) - shippingMethod: string; - - @Column({ name: 'tracking_number', length: 100, nullable: true }) - trackingNumber: string; - - @Column({ name: 'carrier_name', length: 100, nullable: true }) - carrierName: string; - - // Users - @Column({ name: 'requested_by', type: 'uuid' }) - requestedBy: string; - - @Column({ name: 'approved_by', type: 'uuid', nullable: true }) - approvedBy: string; - - @Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true }) - approvedAt: Date; - - @Column({ name: 'shipped_by', type: 'uuid', nullable: true }) - shippedBy: string; - - @Column({ name: 'received_by', type: 'uuid', nullable: true }) - receivedBy: string; - - // Priority - @Column({ type: 'int', default: 0 }) - priority: number; // 0=normal, 1=high, 2=urgent - - // Reason - @Column({ type: 'text', nullable: true }) - reason: string; - - // Reference (for transfers generated from sales, etc.) - @Column({ name: 'reference_type', length: 50, nullable: true }) - referenceType: string; - - @Column({ name: 'reference_id', type: 'uuid', nullable: true }) - referenceId: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - @Column({ name: 'receiving_notes', type: 'text', nullable: true }) - receivingNotes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - // Relations - @OneToMany(() => StockTransferLine, (line) => line.transfer) - lines: StockTransferLine[]; -} diff --git a/backend/src/modules/inventory/index.ts b/backend/src/modules/inventory/index.ts deleted file mode 100644 index 4dd6d0e..0000000 --- a/backend/src/modules/inventory/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './entities'; -export * from './services'; -export * from './controllers/inventory.controller'; -export * from './routes/inventory.routes'; -export * from './validation/inventory.schema'; diff --git a/backend/src/modules/inventory/routes/inventory.routes.ts b/backend/src/modules/inventory/routes/inventory.routes.ts deleted file mode 100644 index 71d8dfe..0000000 --- a/backend/src/modules/inventory/routes/inventory.routes.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { Router } from 'express'; -import { InventoryController } from '../controllers/inventory.controller'; -import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware'; -import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware'; -import { - createTransferSchema, - shipTransferSchema, - receiveTransferSchema, - cancelTransferSchema, - listTransfersQuerySchema, - createAdjustmentSchema, - addAdjustmentLineSchema, - rejectAdjustmentSchema, - cancelAdjustmentSchema, - listAdjustmentsQuerySchema, -} from '../validation/inventory.schema'; - -export function createInventoryRoutes(controller: InventoryController): Router { - const router = Router(); - - // Apply auth middleware to all routes - router.use(requireAuth); - - // ==================== TRANSFER ROUTES ==================== - - // Create transfer - router.post( - '/transfers', - requirePermissions(['inventory.transfers.create']), - validateBody(createTransferSchema), - controller.createTransfer - ); - - // List transfers - router.get( - '/transfers', - requirePermissions(['inventory.transfers.view']), - validateQuery(listTransfersQuerySchema), - controller.listTransfers - ); - - // Get transfer summary - router.get( - '/transfers/summary', - requirePermissions(['inventory.transfers.view']), - controller.getTransferSummary - ); - - // Get incoming transfers - router.get( - '/transfers/incoming', - requirePermissions(['inventory.transfers.view']), - controller.getIncomingTransfers - ); - - // Get transfer by ID - router.get( - '/transfers/:id', - requirePermissions(['inventory.transfers.view']), - controller.getTransfer - ); - - // Submit transfer for approval - router.post( - '/transfers/:id/submit', - requirePermissions(['inventory.transfers.submit']), - controller.submitTransfer - ); - - // Approve transfer - router.post( - '/transfers/:id/approve', - requireRoles(['admin', 'warehouse_manager', 'manager']), - controller.approveTransfer - ); - - // Ship transfer - router.post( - '/transfers/:id/ship', - requirePermissions(['inventory.transfers.ship']), - validateBody(shipTransferSchema), - controller.shipTransfer - ); - - // Receive transfer - router.post( - '/transfers/:id/receive', - requirePermissions(['inventory.transfers.receive']), - validateBody(receiveTransferSchema), - controller.receiveTransfer - ); - - // Cancel transfer - router.post( - '/transfers/:id/cancel', - requirePermissions(['inventory.transfers.cancel']), - validateBody(cancelTransferSchema), - controller.cancelTransfer - ); - - // ==================== ADJUSTMENT ROUTES ==================== - - // Create adjustment - router.post( - '/adjustments', - requirePermissions(['inventory.adjustments.create']), - validateBody(createAdjustmentSchema), - controller.createAdjustment - ); - - // List adjustments - router.get( - '/adjustments', - requirePermissions(['inventory.adjustments.view']), - validateQuery(listAdjustmentsQuerySchema), - controller.listAdjustments - ); - - // Get adjustment summary - router.get( - '/adjustments/summary', - requirePermissions(['inventory.adjustments.view']), - controller.getAdjustmentSummary - ); - - // Get adjustment by ID - router.get( - '/adjustments/:id', - requirePermissions(['inventory.adjustments.view']), - controller.getAdjustment - ); - - // Add line to adjustment - router.post( - '/adjustments/:id/lines', - requirePermissions(['inventory.adjustments.edit']), - validateBody(addAdjustmentLineSchema), - controller.addAdjustmentLine - ); - - // Submit adjustment for approval - router.post( - '/adjustments/:id/submit', - requirePermissions(['inventory.adjustments.submit']), - controller.submitAdjustment - ); - - // Approve adjustment - router.post( - '/adjustments/:id/approve', - requireRoles(['admin', 'warehouse_manager', 'manager']), - controller.approveAdjustment - ); - - // Reject adjustment - router.post( - '/adjustments/:id/reject', - requireRoles(['admin', 'warehouse_manager', 'manager']), - validateBody(rejectAdjustmentSchema), - controller.rejectAdjustment - ); - - // Post adjustment - router.post( - '/adjustments/:id/post', - requireRoles(['admin', 'warehouse_manager']), - controller.postAdjustment - ); - - // Cancel adjustment - router.post( - '/adjustments/:id/cancel', - requirePermissions(['inventory.adjustments.cancel']), - validateBody(cancelAdjustmentSchema), - controller.cancelAdjustment - ); - - return router; -} diff --git a/backend/src/modules/inventory/services/index.ts b/backend/src/modules/inventory/services/index.ts deleted file mode 100644 index d225f9b..0000000 --- a/backend/src/modules/inventory/services/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './stock-transfer.service'; -export * from './stock-adjustment.service'; diff --git a/backend/src/modules/inventory/services/stock-adjustment.service.ts b/backend/src/modules/inventory/services/stock-adjustment.service.ts deleted file mode 100644 index 73c961d..0000000 --- a/backend/src/modules/inventory/services/stock-adjustment.service.ts +++ /dev/null @@ -1,630 +0,0 @@ -import { Repository, DataSource, Between } from 'typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult, QueryOptions } from '../../../shared/types'; -import { - StockAdjustment, - AdjustmentStatus, - AdjustmentType, -} from '../entities/stock-adjustment.entity'; -import { StockAdjustmentLine, AdjustmentDirection } from '../entities/stock-adjustment-line.entity'; - -export interface AdjustmentLineInput { - productId: string; - productCode: string; - productName: string; - productBarcode?: string; - variantId?: string; - variantName?: string; - uomId?: string; - uomName?: string; - systemQuantity: number; - countedQuantity: number; - unitCost?: number; - locationId?: string; - locationCode?: string; - lotNumber?: string; - serialNumber?: string; - expiryDate?: Date; - lineReason?: string; - notes?: string; -} - -export interface CreateAdjustmentInput { - branchId: string; - warehouseId: string; - locationId?: string; - type: AdjustmentType; - adjustmentDate: Date; - isFullCount?: boolean; - countCategoryId?: string; - reason: string; - notes?: string; - lines: AdjustmentLineInput[]; -} - -export interface AdjustmentQueryOptions extends QueryOptions { - branchId?: string; - warehouseId?: string; - status?: AdjustmentStatus; - type?: AdjustmentType; - startDate?: Date; - endDate?: Date; -} - -export class StockAdjustmentService extends BaseService { - constructor( - repository: Repository, - private readonly lineRepository: Repository, - private readonly dataSource: DataSource - ) { - super(repository); - } - - /** - * Generate adjustment number - */ - private async generateAdjustmentNumber(tenantId: string, type: AdjustmentType): Promise { - const today = new Date(); - const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); - const typePrefix = type === AdjustmentType.INVENTORY_COUNT ? 'CNT' : 'ADJ'; - - const count = await this.repository.count({ - where: { - tenantId, - createdAt: Between( - new Date(today.setHours(0, 0, 0, 0)), - new Date(today.setHours(23, 59, 59, 999)) - ), - }, - }); - - return `${typePrefix}-${datePrefix}-${String(count + 1).padStart(4, '0')}`; - } - - /** - * Create a new stock adjustment - */ - async createAdjustment( - tenantId: string, - input: CreateAdjustmentInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const number = await this.generateAdjustmentNumber(tenantId, input.type); - - // Calculate totals - let totalIncreaseQty = 0; - let totalDecreaseQty = 0; - let totalIncreaseValue = 0; - let totalDecreaseValue = 0; - - for (const line of input.lines) { - const adjustmentQty = line.countedQuantity - line.systemQuantity; - const unitCost = line.unitCost ?? 0; - - if (adjustmentQty > 0) { - totalIncreaseQty += adjustmentQty; - totalIncreaseValue += adjustmentQty * unitCost; - } else { - totalDecreaseQty += Math.abs(adjustmentQty); - totalDecreaseValue += Math.abs(adjustmentQty) * unitCost; - } - } - - const netAdjustmentValue = totalIncreaseValue - totalDecreaseValue; - - // Determine if approval is required - const approvalThreshold = 10000; // Could be configurable - const requiresApproval = Math.abs(netAdjustmentValue) >= approvalThreshold; - - const adjustment = queryRunner.manager.create(StockAdjustment, { - tenantId, - branchId: input.branchId, - warehouseId: input.warehouseId, - locationId: input.locationId, - number, - type: input.type, - status: AdjustmentStatus.DRAFT, - adjustmentDate: input.adjustmentDate, - isFullCount: input.isFullCount ?? false, - countCategoryId: input.countCategoryId, - linesCount: input.lines.length, - totalIncreaseQty, - totalDecreaseQty, - totalIncreaseValue, - totalDecreaseValue, - netAdjustmentValue, - requiresApproval, - approvalThreshold: requiresApproval ? approvalThreshold : null, - reason: input.reason, - notes: input.notes, - createdBy: userId, - }); - - const savedAdjustment = await queryRunner.manager.save(adjustment); - - // Create lines - let lineNumber = 1; - for (const lineInput of input.lines) { - const adjustmentQuantity = lineInput.countedQuantity - lineInput.systemQuantity; - const direction = adjustmentQuantity >= 0 ? AdjustmentDirection.INCREASE : AdjustmentDirection.DECREASE; - const unitCost = lineInput.unitCost ?? 0; - - const line = queryRunner.manager.create(StockAdjustmentLine, { - tenantId, - adjustmentId: savedAdjustment.id, - lineNumber: lineNumber++, - productId: lineInput.productId, - productCode: lineInput.productCode, - productName: lineInput.productName, - productBarcode: lineInput.productBarcode, - variantId: lineInput.variantId, - variantName: lineInput.variantName, - uomId: lineInput.uomId, - uomName: lineInput.uomName ?? 'PZA', - systemQuantity: lineInput.systemQuantity, - countedQuantity: lineInput.countedQuantity, - adjustmentQuantity: Math.abs(adjustmentQuantity), - direction, - unitCost, - adjustmentValue: Math.abs(adjustmentQuantity) * unitCost, - locationId: lineInput.locationId, - locationCode: lineInput.locationCode, - lotNumber: lineInput.lotNumber, - serialNumber: lineInput.serialNumber, - expiryDate: lineInput.expiryDate, - lineReason: lineInput.lineReason, - notes: lineInput.notes, - }); - - await queryRunner.manager.save(line); - } - - await queryRunner.commitTransaction(); - - // Reload with lines - const result = await this.repository.findOne({ - where: { id: savedAdjustment.id, tenantId }, - relations: ['lines'], - }); - - return { success: true, data: result! }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_ADJUSTMENT_ERROR', - message: error instanceof Error ? error.message : 'Failed to create adjustment', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Submit adjustment for approval - */ - async submitForApproval( - tenantId: string, - id: string, - userId: string - ): Promise> { - const adjustment = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!adjustment) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, - }; - } - - if (adjustment.status !== AdjustmentStatus.DRAFT) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Adjustment is not in draft status' }, - }; - } - - adjustment.status = adjustment.requiresApproval - ? AdjustmentStatus.PENDING_APPROVAL - : AdjustmentStatus.APPROVED; - - if (!adjustment.requiresApproval) { - adjustment.approvedBy = userId; - adjustment.approvedAt = new Date(); - } - - const saved = await this.repository.save(adjustment); - return { success: true, data: saved }; - } - - /** - * Approve adjustment - */ - async approveAdjustment( - tenantId: string, - id: string, - approverId: string - ): Promise> { - const adjustment = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!adjustment) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, - }; - } - - if (adjustment.status !== AdjustmentStatus.PENDING_APPROVAL) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Adjustment is not pending approval' }, - }; - } - - adjustment.status = AdjustmentStatus.APPROVED; - adjustment.approvedBy = approverId; - adjustment.approvedAt = new Date(); - - const saved = await this.repository.save(adjustment); - return { success: true, data: saved }; - } - - /** - * Reject adjustment - */ - async rejectAdjustment( - tenantId: string, - id: string, - rejecterId: string, - reason: string - ): Promise> { - const adjustment = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!adjustment) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, - }; - } - - if (adjustment.status !== AdjustmentStatus.PENDING_APPROVAL) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Adjustment is not pending approval' }, - }; - } - - adjustment.status = AdjustmentStatus.REJECTED; - adjustment.rejectedBy = rejecterId; - adjustment.rejectedAt = new Date(); - adjustment.rejectionReason = reason; - - const saved = await this.repository.save(adjustment); - return { success: true, data: saved }; - } - - /** - * Post adjustment (apply to inventory) - */ - async postAdjustment( - tenantId: string, - id: string, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const adjustment = await queryRunner.manager.findOne(StockAdjustment, { - where: { id, tenantId }, - relations: ['lines'], - }); - - if (!adjustment) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, - }; - } - - if (adjustment.status !== AdjustmentStatus.APPROVED) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Adjustment must be approved before posting' }, - }; - } - - // TODO: Update actual inventory stock levels - // This would involve updating stock records in the inventory system - // For each line: - // - If direction is INCREASE, add adjustmentQuantity to stock - // - If direction is DECREASE, subtract adjustmentQuantity from stock - - adjustment.status = AdjustmentStatus.POSTED; - adjustment.postedBy = userId; - adjustment.postedAt = new Date(); - - await queryRunner.manager.save(adjustment); - await queryRunner.commitTransaction(); - - return { success: true, data: adjustment }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'POST_ADJUSTMENT_ERROR', - message: error instanceof Error ? error.message : 'Failed to post adjustment', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Cancel adjustment - */ - async cancelAdjustment( - tenantId: string, - id: string, - reason: string, - userId: string - ): Promise> { - const adjustment = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!adjustment) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, - }; - } - - if ([AdjustmentStatus.POSTED, AdjustmentStatus.CANCELLED].includes(adjustment.status)) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Cannot cancel a posted or already cancelled adjustment' }, - }; - } - - adjustment.status = AdjustmentStatus.CANCELLED; - adjustment.notes = `${adjustment.notes ?? ''}\nCancelled: ${reason}`.trim(); - - const saved = await this.repository.save(adjustment); - return { success: true, data: saved }; - } - - /** - * Find adjustments with filters - */ - async findAdjustments( - tenantId: string, - options: AdjustmentQueryOptions - ): Promise<{ data: StockAdjustment[]; total: number }> { - const qb = this.repository.createQueryBuilder('adjustment') - .where('adjustment.tenantId = :tenantId', { tenantId }); - - if (options.branchId) { - qb.andWhere('adjustment.branchId = :branchId', { branchId: options.branchId }); - } - if (options.warehouseId) { - qb.andWhere('adjustment.warehouseId = :warehouseId', { warehouseId: options.warehouseId }); - } - if (options.status) { - qb.andWhere('adjustment.status = :status', { status: options.status }); - } - if (options.type) { - qb.andWhere('adjustment.type = :type', { type: options.type }); - } - if (options.startDate && options.endDate) { - qb.andWhere('adjustment.adjustmentDate BETWEEN :startDate AND :endDate', { - startDate: options.startDate, - endDate: options.endDate, - }); - } - - const page = options.page ?? 1; - const limit = options.limit ?? 20; - qb.skip((page - 1) * limit).take(limit); - qb.orderBy('adjustment.adjustmentDate', 'DESC'); - - const [data, total] = await qb.getManyAndCount(); - return { data, total }; - } - - /** - * Get adjustment with lines - */ - async getAdjustmentWithLines( - tenantId: string, - id: string - ): Promise { - return this.repository.findOne({ - where: { id, tenantId }, - relations: ['lines'], - }); - } - - /** - * Get adjustment summary by type and status - */ - async getAdjustmentSummary( - tenantId: string, - branchId?: string, - startDate?: Date, - endDate?: Date - ): Promise<{ - byType: Record; - byStatus: Record; - totalIncreaseValue: number; - totalDecreaseValue: number; - netValue: number; - }> { - const qb = this.repository.createQueryBuilder('adjustment') - .where('adjustment.tenantId = :tenantId', { tenantId }); - - if (branchId) { - qb.andWhere('adjustment.branchId = :branchId', { branchId }); - } - if (startDate && endDate) { - qb.andWhere('adjustment.adjustmentDate BETWEEN :startDate AND :endDate', { - startDate, - endDate, - }); - } - - const adjustments = await qb.getMany(); - - const byType: Record = {}; - const byStatus: Record = {}; - let totalIncreaseValue = 0; - let totalDecreaseValue = 0; - - for (const adj of adjustments) { - // By type - if (!byType[adj.type]) { - byType[adj.type] = { count: 0, value: 0 }; - } - byType[adj.type].count++; - byType[adj.type].value += Number(adj.netAdjustmentValue); - - // By status - byStatus[adj.status] = (byStatus[adj.status] ?? 0) + 1; - - // Totals - totalIncreaseValue += Number(adj.totalIncreaseValue); - totalDecreaseValue += Number(adj.totalDecreaseValue); - } - - return { - byType: byType as Record, - byStatus: byStatus as Record, - totalIncreaseValue, - totalDecreaseValue, - netValue: totalIncreaseValue - totalDecreaseValue, - }; - } - - /** - * Add line to existing adjustment - */ - async addLine( - tenantId: string, - adjustmentId: string, - lineInput: AdjustmentLineInput, - userId: string - ): Promise> { - const adjustment = await this.repository.findOne({ - where: { id: adjustmentId, tenantId }, - }); - - if (!adjustment) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, - }; - } - - if (adjustment.status !== AdjustmentStatus.DRAFT) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Can only add lines to draft adjustments' }, - }; - } - - const lastLine = await this.lineRepository.findOne({ - where: { adjustmentId, tenantId }, - order: { lineNumber: 'DESC' }, - }); - - const adjustmentQuantity = lineInput.countedQuantity - lineInput.systemQuantity; - const direction = adjustmentQuantity >= 0 ? AdjustmentDirection.INCREASE : AdjustmentDirection.DECREASE; - const unitCost = lineInput.unitCost ?? 0; - - const line = this.lineRepository.create({ - tenantId, - adjustmentId, - lineNumber: (lastLine?.lineNumber ?? 0) + 1, - productId: lineInput.productId, - productCode: lineInput.productCode, - productName: lineInput.productName, - productBarcode: lineInput.productBarcode, - variantId: lineInput.variantId, - variantName: lineInput.variantName, - uomId: lineInput.uomId, - uomName: lineInput.uomName ?? 'PZA', - systemQuantity: lineInput.systemQuantity, - countedQuantity: lineInput.countedQuantity, - adjustmentQuantity: Math.abs(adjustmentQuantity), - direction, - unitCost, - adjustmentValue: Math.abs(adjustmentQuantity) * unitCost, - locationId: lineInput.locationId, - locationCode: lineInput.locationCode, - lotNumber: lineInput.lotNumber, - serialNumber: lineInput.serialNumber, - expiryDate: lineInput.expiryDate, - lineReason: lineInput.lineReason, - notes: lineInput.notes, - }); - - const saved = await this.lineRepository.save(line); - - // Update adjustment totals - await this.recalculateTotals(tenantId, adjustmentId); - - return { success: true, data: saved }; - } - - /** - * Recalculate adjustment totals from lines - */ - private async recalculateTotals(tenantId: string, adjustmentId: string): Promise { - const lines = await this.lineRepository.find({ - where: { adjustmentId, tenantId }, - }); - - let totalIncreaseQty = 0; - let totalDecreaseQty = 0; - let totalIncreaseValue = 0; - let totalDecreaseValue = 0; - - for (const line of lines) { - if (line.direction === AdjustmentDirection.INCREASE) { - totalIncreaseQty += Number(line.adjustmentQuantity); - totalIncreaseValue += Number(line.adjustmentValue); - } else { - totalDecreaseQty += Number(line.adjustmentQuantity); - totalDecreaseValue += Number(line.adjustmentValue); - } - } - - await this.repository.update( - { id: adjustmentId, tenantId }, - { - linesCount: lines.length, - totalIncreaseQty, - totalDecreaseQty, - totalIncreaseValue, - totalDecreaseValue, - netAdjustmentValue: totalIncreaseValue - totalDecreaseValue, - } - ); - } -} diff --git a/backend/src/modules/inventory/services/stock-transfer.service.ts b/backend/src/modules/inventory/services/stock-transfer.service.ts deleted file mode 100644 index aeaf13e..0000000 --- a/backend/src/modules/inventory/services/stock-transfer.service.ts +++ /dev/null @@ -1,591 +0,0 @@ -import { Repository, DataSource, Between, In } from 'typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult, QueryOptions } from '../../../shared/types'; -import { - StockTransfer, - TransferStatus, - TransferType, -} from '../entities/stock-transfer.entity'; -import { StockTransferLine, TransferLineStatus } from '../entities/stock-transfer-line.entity'; - -export interface TransferLineInput { - productId: string; - productCode: string; - productName: string; - productBarcode?: string; - variantId?: string; - variantName?: string; - uomId?: string; - uomName?: string; - quantityRequested: number; - unitCost?: number; - lotNumber?: string; - serialNumbers?: string[]; - expiryDate?: Date; - notes?: string; -} - -export interface CreateTransferInput { - type: TransferType; - sourceBranchId?: string; - sourceWarehouseId: string; - sourceLocationId?: string; - destBranchId?: string; - destWarehouseId: string; - destLocationId?: string; - requestedDate: Date; - expectedDate?: Date; - priority?: number; - reason?: string; - notes?: string; - lines: TransferLineInput[]; -} - -export interface TransferQueryOptions extends QueryOptions { - branchId?: string; - sourceBranchId?: string; - destBranchId?: string; - status?: TransferStatus; - type?: TransferType; - startDate?: Date; - endDate?: Date; -} - -export interface ShipLineInput { - lineId: string; - quantityShipped: number; - lotNumber?: string; - serialNumbers?: string[]; -} - -export interface ReceiveLineInput { - lineId: string; - quantityReceived: number; - damageQuantity?: number; - damageReason?: string; - receivingNotes?: string; -} - -export class StockTransferService extends BaseService { - constructor( - repository: Repository, - private readonly lineRepository: Repository, - private readonly dataSource: DataSource - ) { - super(repository); - } - - /** - * Generate transfer number - */ - private async generateTransferNumber(tenantId: string): Promise { - const today = new Date(); - const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); - - const count = await this.repository.count({ - where: { - tenantId, - createdAt: Between( - new Date(today.setHours(0, 0, 0, 0)), - new Date(today.setHours(23, 59, 59, 999)) - ), - }, - }); - - return `TRF-${datePrefix}-${String(count + 1).padStart(4, '0')}`; - } - - /** - * Create a new stock transfer - */ - async createTransfer( - tenantId: string, - input: CreateTransferInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const number = await this.generateTransferNumber(tenantId); - - // Calculate totals - let totalItems = 0; - let totalCost = 0; - for (const line of input.lines) { - totalItems += line.quantityRequested; - totalCost += (line.unitCost ?? 0) * line.quantityRequested; - } - - const transfer = queryRunner.manager.create(StockTransfer, { - tenantId, - number, - type: input.type, - status: TransferStatus.DRAFT, - sourceBranchId: input.sourceBranchId, - sourceWarehouseId: input.sourceWarehouseId, - sourceLocationId: input.sourceLocationId, - destBranchId: input.destBranchId, - destWarehouseId: input.destWarehouseId, - destLocationId: input.destLocationId, - requestedDate: input.requestedDate, - expectedDate: input.expectedDate, - linesCount: input.lines.length, - itemsRequested: totalItems, - totalCost, - totalValue: totalCost, - priority: input.priority ?? 0, - reason: input.reason, - notes: input.notes, - requestedBy: userId, - }); - - const savedTransfer = await queryRunner.manager.save(transfer); - - // Create lines - let lineNumber = 1; - for (const lineInput of input.lines) { - const line = queryRunner.manager.create(StockTransferLine, { - tenantId, - transferId: savedTransfer.id, - lineNumber: lineNumber++, - productId: lineInput.productId, - productCode: lineInput.productCode, - productName: lineInput.productName, - productBarcode: lineInput.productBarcode, - variantId: lineInput.variantId, - variantName: lineInput.variantName, - uomId: lineInput.uomId, - uomName: lineInput.uomName ?? 'PZA', - quantityRequested: lineInput.quantityRequested, - unitCost: lineInput.unitCost ?? 0, - totalCost: (lineInput.unitCost ?? 0) * lineInput.quantityRequested, - lotNumber: lineInput.lotNumber, - serialNumbers: lineInput.serialNumbers, - expiryDate: lineInput.expiryDate, - notes: lineInput.notes, - status: TransferLineStatus.PENDING, - }); - - await queryRunner.manager.save(line); - } - - await queryRunner.commitTransaction(); - - // Reload with lines - const result = await this.repository.findOne({ - where: { id: savedTransfer.id, tenantId }, - relations: ['lines'], - }); - - return { success: true, data: result! }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_TRANSFER_ERROR', - message: error instanceof Error ? error.message : 'Failed to create transfer', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Submit transfer for approval - */ - async submitForApproval( - tenantId: string, - id: string, - userId: string - ): Promise> { - const transfer = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!transfer) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Transfer not found' }, - }; - } - - if (transfer.status !== TransferStatus.DRAFT) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Transfer is not in draft status' }, - }; - } - - transfer.status = TransferStatus.PENDING_APPROVAL; - const saved = await this.repository.save(transfer); - - return { success: true, data: saved }; - } - - /** - * Approve transfer - */ - async approveTransfer( - tenantId: string, - id: string, - approverId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const transfer = await queryRunner.manager.findOne(StockTransfer, { - where: { id, tenantId }, - relations: ['lines'], - }); - - if (!transfer) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Transfer not found' }, - }; - } - - if (transfer.status !== TransferStatus.PENDING_APPROVAL) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Transfer is not pending approval' }, - }; - } - - transfer.status = TransferStatus.APPROVED; - transfer.approvedBy = approverId; - transfer.approvedAt = new Date(); - - // Update lines with approved quantities - for (const line of transfer.lines) { - line.quantityApproved = line.quantityRequested; - await queryRunner.manager.save(line); - } - - await queryRunner.manager.save(transfer); - await queryRunner.commitTransaction(); - - return { success: true, data: transfer }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'APPROVE_ERROR', - message: error instanceof Error ? error.message : 'Failed to approve transfer', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Ship transfer (mark as in transit) - */ - async shipTransfer( - tenantId: string, - id: string, - shipLines: ShipLineInput[], - shippingInfo: { method?: string; trackingNumber?: string; carrierName?: string }, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const transfer = await queryRunner.manager.findOne(StockTransfer, { - where: { id, tenantId }, - relations: ['lines'], - }); - - if (!transfer) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Transfer not found' }, - }; - } - - if (transfer.status !== TransferStatus.APPROVED) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Transfer must be approved before shipping' }, - }; - } - - let totalShipped = 0; - for (const shipLine of shipLines) { - const line = transfer.lines.find(l => l.id === shipLine.lineId); - if (line) { - line.quantityShipped = shipLine.quantityShipped; - line.lotNumber = shipLine.lotNumber ?? line.lotNumber; - line.serialNumbers = shipLine.serialNumbers ?? line.serialNumbers; - line.status = shipLine.quantityShipped >= Number(line.quantityApproved ?? line.quantityRequested) - ? TransferLineStatus.SHIPPED - : TransferLineStatus.PARTIALLY_SHIPPED; - totalShipped += shipLine.quantityShipped; - await queryRunner.manager.save(line); - } - } - - transfer.status = TransferStatus.IN_TRANSIT; - transfer.itemsShipped = totalShipped; - transfer.shippedDate = new Date(); - transfer.shippedBy = userId; - transfer.shippingMethod = shippingInfo.method; - transfer.trackingNumber = shippingInfo.trackingNumber; - transfer.carrierName = shippingInfo.carrierName; - - await queryRunner.manager.save(transfer); - await queryRunner.commitTransaction(); - - return { success: true, data: transfer }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'SHIP_ERROR', - message: error instanceof Error ? error.message : 'Failed to ship transfer', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Receive transfer - */ - async receiveTransfer( - tenantId: string, - id: string, - receiveLines: ReceiveLineInput[], - notes: string | null, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const transfer = await queryRunner.manager.findOne(StockTransfer, { - where: { id, tenantId }, - relations: ['lines'], - }); - - if (!transfer) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Transfer not found' }, - }; - } - - if (![TransferStatus.IN_TRANSIT, TransferStatus.PARTIALLY_RECEIVED].includes(transfer.status)) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Transfer must be in transit to receive' }, - }; - } - - let totalReceived = 0; - let allFullyReceived = true; - - for (const receiveLine of receiveLines) { - const line = transfer.lines.find(l => l.id === receiveLine.lineId); - if (line) { - line.quantityReceived = Number(line.quantityReceived) + receiveLine.quantityReceived; - line.damageQuantity = receiveLine.damageQuantity ?? 0; - line.damageReason = receiveLine.damageReason; - line.receivingNotes = receiveLine.receivingNotes; - line.receivedDate = new Date(); - line.receivedBy = userId; - line.quantityDifference = Number(line.quantityShipped) - Number(line.quantityReceived); - - if (line.quantityReceived >= Number(line.quantityShipped)) { - line.status = TransferLineStatus.RECEIVED; - } else { - line.status = TransferLineStatus.PARTIALLY_RECEIVED; - allFullyReceived = false; - } - - totalReceived += receiveLine.quantityReceived; - await queryRunner.manager.save(line); - } - } - - transfer.itemsReceived = Number(transfer.itemsReceived) + totalReceived; - transfer.receivedDate = new Date(); - transfer.receivedBy = userId; - transfer.receivingNotes = notes; - transfer.status = allFullyReceived ? TransferStatus.RECEIVED : TransferStatus.PARTIALLY_RECEIVED; - - await queryRunner.manager.save(transfer); - await queryRunner.commitTransaction(); - - // TODO: Update actual inventory stock levels - - return { success: true, data: transfer }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'RECEIVE_ERROR', - message: error instanceof Error ? error.message : 'Failed to receive transfer', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Cancel transfer - */ - async cancelTransfer( - tenantId: string, - id: string, - reason: string, - userId: string - ): Promise> { - const transfer = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!transfer) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Transfer not found' }, - }; - } - - if ([TransferStatus.RECEIVED, TransferStatus.CANCELLED].includes(transfer.status)) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Cannot cancel a received or already cancelled transfer' }, - }; - } - - transfer.status = TransferStatus.CANCELLED; - transfer.notes = `${transfer.notes ?? ''}\nCancelled: ${reason}`.trim(); - - // Update lines - await this.lineRepository.update( - { transferId: id, tenantId }, - { status: TransferLineStatus.CANCELLED } - ); - - const saved = await this.repository.save(transfer); - return { success: true, data: saved }; - } - - /** - * Find transfers with filters - */ - async findTransfers( - tenantId: string, - options: TransferQueryOptions - ): Promise<{ data: StockTransfer[]; total: number }> { - const qb = this.repository.createQueryBuilder('transfer') - .where('transfer.tenantId = :tenantId', { tenantId }); - - if (options.branchId) { - qb.andWhere('(transfer.sourceBranchId = :branchId OR transfer.destBranchId = :branchId)', { - branchId: options.branchId, - }); - } - if (options.sourceBranchId) { - qb.andWhere('transfer.sourceBranchId = :sourceBranchId', { sourceBranchId: options.sourceBranchId }); - } - if (options.destBranchId) { - qb.andWhere('transfer.destBranchId = :destBranchId', { destBranchId: options.destBranchId }); - } - if (options.status) { - qb.andWhere('transfer.status = :status', { status: options.status }); - } - if (options.type) { - qb.andWhere('transfer.type = :type', { type: options.type }); - } - if (options.startDate && options.endDate) { - qb.andWhere('transfer.requestedDate BETWEEN :startDate AND :endDate', { - startDate: options.startDate, - endDate: options.endDate, - }); - } - - const page = options.page ?? 1; - const limit = options.limit ?? 20; - qb.skip((page - 1) * limit).take(limit); - qb.orderBy('transfer.createdAt', 'DESC'); - - const [data, total] = await qb.getManyAndCount(); - return { data, total }; - } - - /** - * Get transfer with lines - */ - async getTransferWithLines( - tenantId: string, - id: string - ): Promise { - return this.repository.findOne({ - where: { id, tenantId }, - relations: ['lines'], - }); - } - - /** - * Get pending transfers for a branch (incoming) - */ - async getPendingIncomingTransfers( - tenantId: string, - branchId: string - ): Promise { - return this.repository.find({ - where: { - tenantId, - destBranchId: branchId, - status: In([TransferStatus.APPROVED, TransferStatus.IN_TRANSIT, TransferStatus.PARTIALLY_RECEIVED]), - }, - relations: ['lines'], - order: { priority: 'DESC', expectedDate: 'ASC' }, - }); - } - - /** - * Get transfer summary by status - */ - async getTransferSummary( - tenantId: string, - branchId?: string - ): Promise> { - const qb = this.repository.createQueryBuilder('transfer') - .select('transfer.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('transfer.tenantId = :tenantId', { tenantId }); - - if (branchId) { - qb.andWhere('(transfer.sourceBranchId = :branchId OR transfer.destBranchId = :branchId)', { branchId }); - } - - qb.groupBy('transfer.status'); - - const results = await qb.getRawMany(); - const summary: Record = {}; - - for (const r of results) { - summary[r.status] = parseInt(r.count, 10); - } - - return summary as Record; - } -} diff --git a/backend/src/modules/inventory/validation/inventory.schema.ts b/backend/src/modules/inventory/validation/inventory.schema.ts deleted file mode 100644 index 44732f1..0000000 --- a/backend/src/modules/inventory/validation/inventory.schema.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { z } from 'zod'; -import { - uuidSchema, - moneySchema, - quantitySchema, - paginationSchema, -} from '../../../shared/validation/common.schema'; - -// Enums -export const transferTypeEnum = z.enum([ - 'branch_to_branch', - 'warehouse_to_branch', - 'branch_to_warehouse', - 'internal', -]); - -export const transferStatusEnum = z.enum([ - 'draft', - 'pending_approval', - 'approved', - 'in_transit', - 'partially_received', - 'received', - 'cancelled', -]); - -export const adjustmentTypeEnum = z.enum([ - 'inventory_count', - 'damage', - 'theft', - 'expiry', - 'correction', - 'initial_stock', - 'production', - 'other', -]); - -export const adjustmentStatusEnum = z.enum([ - 'draft', - 'pending_approval', - 'approved', - 'posted', - 'rejected', - 'cancelled', -]); - -// ==================== TRANSFER SCHEMAS ==================== - -// Transfer line schema -export const transferLineSchema = z.object({ - productId: uuidSchema, - productCode: z.string().min(1).max(50), - productName: z.string().min(1).max(200), - productBarcode: z.string().max(50).optional(), - variantId: uuidSchema.optional(), - variantName: z.string().max(200).optional(), - uomId: uuidSchema.optional(), - uomName: z.string().max(20).optional(), - quantityRequested: quantitySchema.positive('Quantity must be positive'), - unitCost: moneySchema.optional(), - lotNumber: z.string().max(50).optional(), - serialNumbers: z.array(z.string().max(50)).optional(), - expiryDate: z.coerce.date().optional(), - notes: z.string().max(500).optional(), -}); - -// Create transfer schema -export const createTransferSchema = z.object({ - type: transferTypeEnum, - sourceBranchId: uuidSchema.optional(), - sourceWarehouseId: uuidSchema, - sourceLocationId: uuidSchema.optional(), - destBranchId: uuidSchema.optional(), - destWarehouseId: uuidSchema, - destLocationId: uuidSchema.optional(), - requestedDate: z.coerce.date(), - expectedDate: z.coerce.date().optional(), - priority: z.number().int().min(0).max(2).optional(), - reason: z.string().max(500).optional(), - notes: z.string().max(1000).optional(), - lines: z.array(transferLineSchema).min(1, 'At least one line is required'), -}); - -// Ship line schema -export const shipLineSchema = z.object({ - lineId: uuidSchema, - quantityShipped: quantitySchema.positive('Quantity must be positive'), - lotNumber: z.string().max(50).optional(), - serialNumbers: z.array(z.string().max(50)).optional(), -}); - -// Ship transfer schema -export const shipTransferSchema = z.object({ - lines: z.array(shipLineSchema).min(1, 'At least one line is required'), - shippingMethod: z.string().max(50).optional(), - trackingNumber: z.string().max(100).optional(), - carrierName: z.string().max(100).optional(), -}); - -// Receive line schema -export const receiveLineSchema = z.object({ - lineId: uuidSchema, - quantityReceived: quantitySchema.min(0), - damageQuantity: quantitySchema.optional(), - damageReason: z.string().max(255).optional(), - receivingNotes: z.string().max(255).optional(), -}); - -// Receive transfer schema -export const receiveTransferSchema = z.object({ - lines: z.array(receiveLineSchema).min(1, 'At least one line is required'), - notes: z.string().max(1000).optional(), -}); - -// Cancel transfer schema -export const cancelTransferSchema = z.object({ - reason: z.string().min(1, 'Reason is required').max(255), -}); - -// List transfers query schema -export const listTransfersQuerySchema = paginationSchema.extend({ - branchId: uuidSchema.optional(), - sourceBranchId: uuidSchema.optional(), - destBranchId: uuidSchema.optional(), - status: transferStatusEnum.optional(), - type: transferTypeEnum.optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), -}); - -// ==================== ADJUSTMENT SCHEMAS ==================== - -// Adjustment line schema -export const adjustmentLineSchema = z.object({ - productId: uuidSchema, - productCode: z.string().min(1).max(50), - productName: z.string().min(1).max(200), - productBarcode: z.string().max(50).optional(), - variantId: uuidSchema.optional(), - variantName: z.string().max(200).optional(), - uomId: uuidSchema.optional(), - uomName: z.string().max(20).optional(), - systemQuantity: quantitySchema, - countedQuantity: quantitySchema, - unitCost: moneySchema.optional(), - locationId: uuidSchema.optional(), - locationCode: z.string().max(50).optional(), - lotNumber: z.string().max(50).optional(), - serialNumber: z.string().max(50).optional(), - expiryDate: z.coerce.date().optional(), - lineReason: z.string().max(255).optional(), - notes: z.string().max(500).optional(), -}); - -// Create adjustment schema -export const createAdjustmentSchema = z.object({ - branchId: uuidSchema, - warehouseId: uuidSchema, - locationId: uuidSchema.optional(), - type: adjustmentTypeEnum, - adjustmentDate: z.coerce.date(), - isFullCount: z.boolean().optional(), - countCategoryId: uuidSchema.optional(), - reason: z.string().min(1, 'Reason is required').max(500), - notes: z.string().max(1000).optional(), - lines: z.array(adjustmentLineSchema).min(1, 'At least one line is required'), -}); - -// Add adjustment line schema -export const addAdjustmentLineSchema = adjustmentLineSchema; - -// Reject adjustment schema -export const rejectAdjustmentSchema = z.object({ - reason: z.string().min(1, 'Rejection reason is required').max(255), -}); - -// Cancel adjustment schema -export const cancelAdjustmentSchema = z.object({ - reason: z.string().min(1, 'Cancellation reason is required').max(255), -}); - -// List adjustments query schema -export const listAdjustmentsQuerySchema = paginationSchema.extend({ - branchId: uuidSchema.optional(), - warehouseId: uuidSchema.optional(), - status: adjustmentStatusEnum.optional(), - type: adjustmentTypeEnum.optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), -}); - -// Types -export type CreateTransferInput = z.infer; -export type TransferLineInput = z.infer; -export type ShipTransferInput = z.infer; -export type ReceiveTransferInput = z.infer; -export type ListTransfersQuery = z.infer; -export type CreateAdjustmentInput = z.infer; -export type AdjustmentLineInput = z.infer; -export type ListAdjustmentsQuery = z.infer; diff --git a/backend/src/modules/invoicing/controllers/cfdi.controller.ts b/backend/src/modules/invoicing/controllers/cfdi.controller.ts deleted file mode 100644 index 75d48cc..0000000 --- a/backend/src/modules/invoicing/controllers/cfdi.controller.ts +++ /dev/null @@ -1,742 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { CFDIService } from '../services/cfdi.service'; -import { CFDIBuilderService } from '../services/cfdi-builder.service'; -import { AppDataSource } from '../../../config/database'; -import { CFDI } from '../entities/cfdi.entity'; -import { CFDIConfig } from '../entities/cfdi-config.entity'; -import { CANCELLATION_REASONS } from '../services/pac.service'; -import { - CreateCFDIInput, - CancelCFDIInput, - ListCFDIsQuery, - ResendEmailInput, - CreateCreditNoteInput, - CreateCFDIConfigInput, - UpdateCFDIConfigInput, - AutofacturaRequest, - LookupOrderInput, - CFDIStatsQuery, -} from '../validation/cfdi.schema'; - -const cfdiService = new CFDIService( - AppDataSource, - AppDataSource.getRepository(CFDI) -); -const builderService = new CFDIBuilderService(); -const configRepository = AppDataSource.getRepository(CFDIConfig); - -// ==================== CFDI ENDPOINTS ==================== - -/** - * Create a new CFDI (invoice) - * POST /api/invoicing/cfdis - */ -export const createCFDI = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const input = req.body as CreateCFDIInput; - - const result = await cfdiService.createCFDI(tenantId, input, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - errors: result.errors, - }); - return; - } - - res.status(201).json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * List CFDIs with filters - * GET /api/invoicing/cfdis - */ -export const listCFDIs = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const query = req.query as unknown as ListCFDIsQuery; - - const result = await cfdiService.listCFDIs(tenantId, query); - - res.json({ - success: true, - data: result.data, - meta: result.meta, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get CFDI by ID - * GET /api/invoicing/cfdis/:id - */ -export const getCFDI = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { id } = req.params; - - const result = await cfdiService.getCFDIById(tenantId, id); - - if (!result.success) { - res.status(404).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get CFDI by UUID - * GET /api/invoicing/cfdis/uuid/:uuid - */ -export const getCFDIByUUID = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { uuid } = req.params; - - const result = await cfdiService.getCFDIByUUID(tenantId, uuid); - - if (!result.success) { - res.status(404).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Stamp a draft CFDI - * POST /api/invoicing/cfdis/:id/stamp - */ -export const stampCFDI = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - - const result = await cfdiService.stampCFDI(tenantId, id, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - data: result.data, // Include CFDI with error info - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Cancel a CFDI - * POST /api/invoicing/cfdis/:id/cancel - */ -export const cancelCFDI = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - const { reason, substituteUuid } = req.body as CancelCFDIInput; - - const result = await cfdiService.cancelCFDI( - tenantId, - id, - reason, - substituteUuid, - userId - ); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - data: result.data, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Verify CFDI status with SAT - * GET /api/invoicing/cfdis/:id/verify - */ -export const verifyCFDI = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { id } = req.params; - - const result = await cfdiService.verifyCFDIStatus(tenantId, id); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Download CFDI XML - * GET /api/invoicing/cfdis/:id/xml - */ -export const downloadXML = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { id } = req.params; - - const result = await cfdiService.downloadXML(tenantId, id); - - if (!result.success || !result.data) { - res.status(404).json({ - success: false, - error: result.error, - }); - return; - } - - res.setHeader('Content-Type', 'application/xml'); - res.setHeader('Content-Disposition', `attachment; filename="${result.data.filename}"`); - res.send(result.data.xml); - } catch (error) { - next(error); - } -}; - -/** - * Resend CFDI via email - * POST /api/invoicing/cfdis/:id/resend-email - */ -export const resendEmail = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { id } = req.params; - const { email } = req.body as ResendEmailInput; - - const result = await cfdiService.resendEmail(tenantId, id, email); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - message: 'Email sent successfully', - }); - } catch (error) { - next(error); - } -}; - -/** - * Create credit note for a CFDI - * POST /api/invoicing/cfdis/:id/credit-note - */ -export const createCreditNote = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - const { lines, reason } = req.body as Omit; - - const result = await cfdiService.createCreditNote( - tenantId, - id, - lines, - reason, - userId - ); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - errors: result.errors, - }); - return; - } - - res.status(201).json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get CFDI statistics - * GET /api/invoicing/stats - */ -export const getCFDIStats = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { startDate, endDate, branchId } = req.query as unknown as CFDIStatsQuery; - - const stats = await cfdiService.getStats( - tenantId, - startDate, - endDate, - branchId - ); - - res.json({ - success: true, - data: stats, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get cancellation reasons - * GET /api/invoicing/cancellation-reasons - */ -export const getCancellationReasons = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const reasons = Object.entries(CANCELLATION_REASONS).map(([code, description]) => ({ - code, - description, - })); - - res.json({ - success: true, - data: reasons, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get UsoCFDI options - * GET /api/invoicing/uso-cfdi-options - */ -export const getUsoCFDIOptions = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const isPersonaFisica = req.query.personaFisica === 'true'; - const options = builderService.getUsoCFDIOptions(isPersonaFisica); - - res.json({ - success: true, - data: options, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get common SAT product codes - * GET /api/invoicing/claves-prod-serv - */ -export const getClavesProdServ = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const claves = builderService.getCommonClavesProdServ(); - - res.json({ - success: true, - data: claves, - }); - } catch (error) { - next(error); - } -}; - -// ==================== CONFIG ENDPOINTS ==================== - -/** - * Get active CFDI configuration - * GET /api/invoicing/config - */ -export const getConfig = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const branchId = req.headers['x-branch-id'] as string | undefined; - - const result = await cfdiService.getActiveConfig(tenantId, branchId); - - if (!result.success) { - res.status(404).json({ - success: false, - error: result.error, - }); - return; - } - - // Remove sensitive fields - const config = result.data!; - const safeConfig = { - ...config, - pacPasswordEncrypted: undefined, - certificateEncrypted: undefined, - privateKeyEncrypted: undefined, - certificatePasswordEncrypted: undefined, - }; - - res.json({ - success: true, - data: safeConfig, - }); - } catch (error) { - next(error); - } -}; - -/** - * Create or update CFDI configuration - * POST /api/invoicing/config - */ -export const upsertConfig = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const input = req.body as CreateCFDIConfigInput; - - // Check if config exists - const existingConfig = await configRepository.findOne({ - where: { tenantId, branchId: input.branchId || (null as any) }, - }); - - let config: CFDIConfig; - - if (existingConfig) { - // Update existing - Object.assign(existingConfig, input); - existingConfig.updatedBy = userId; - config = await configRepository.save(existingConfig); - } else { - // Create new - config = configRepository.create({ - ...input, - tenantId, - createdBy: userId, - }); - config = await configRepository.save(config); - } - - res.json({ - success: true, - data: config, - }); - } catch (error) { - next(error); - } -}; - -/** - * Upload certificate (CSD) - * POST /api/invoicing/config/certificate - */ -export const uploadCertificate = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { certificate, privateKey, password, configId } = req.body; - - if (!certificate || !privateKey || !password) { - res.status(400).json({ - success: false, - error: 'Certificate, private key, and password are required', - }); - return; - } - - const config = await configRepository.findOne({ - where: configId - ? { id: configId, tenantId } - : { tenantId, branchId: null as any }, - }); - - if (!config) { - res.status(404).json({ - success: false, - error: 'CFDI configuration not found', - }); - return; - } - - // Import PACService for encryption - const { PACService } = await import('../services/pac.service'); - const pacService = new PACService(); - - // TODO: Validate certificate - // - Check it's a valid X.509 certificate - // - Check private key matches - // - Extract certificate number - // - Extract validity dates - - // Encrypt and save - config.certificateEncrypted = pacService.encrypt(certificate); - config.privateKeyEncrypted = pacService.encrypt(privateKey); - config.certificatePasswordEncrypted = pacService.encrypt(password); - config.updatedBy = userId; - - await configRepository.save(config); - - res.json({ - success: true, - message: 'Certificate uploaded successfully', - }); - } catch (error) { - next(error); - } -}; - -/** - * Validate CFDI configuration - * POST /api/invoicing/config/validate - */ -export const validateConfig = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { testStamp } = req.body; - - const configResult = await cfdiService.getActiveConfig(tenantId); - - if (!configResult.success || !configResult.data) { - res.status(404).json({ - success: false, - error: configResult.error, - }); - return; - } - - const config = configResult.data; - const errors: string[] = []; - - // Validate required fields - if (!config.rfc) errors.push('RFC is required'); - if (!config.razonSocial) errors.push('Razón Social is required'); - if (!config.regimenFiscal) errors.push('Régimen Fiscal is required'); - if (!config.codigoPostal) errors.push('Código Postal is required'); - - // Validate certificate - if (!config.certificateEncrypted) { - errors.push('Certificate (CSD) is not uploaded'); - } - if (!config.privateKeyEncrypted) { - errors.push('Private key is not uploaded'); - } - - // Check certificate validity - if (config.certificateValidUntil && config.certificateValidUntil < new Date()) { - errors.push('Certificate has expired'); - } - - // Validate PAC credentials - if (!config.pacUsername) errors.push('PAC username is required'); - if (!config.pacPasswordEncrypted) errors.push('PAC password is required'); - - if (errors.length > 0) { - res.json({ - success: true, - data: { - valid: false, - errors, - }, - }); - return; - } - - // TODO: If testStamp is true, attempt a test stamp with the PAC - - res.json({ - success: true, - data: { - valid: true, - errors: [], - }, - }); - } catch (error) { - next(error); - } -}; - -// ==================== AUTOFACTURA ENDPOINTS ==================== - -/** - * Lookup order for autofactura - * POST /api/invoicing/autofactura/lookup - */ -export const lookupOrder = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { orderNumber, orderDate, orderAmount } = req.body as LookupOrderInput; - - // TODO: Implement order lookup - // This would search for the order in pos_orders or ecommerce_orders - - res.json({ - success: true, - data: { - found: false, - message: 'Order lookup not yet implemented', - }, - }); - } catch (error) { - next(error); - } -}; - -/** - * Create CFDI from autofactura request - * POST /api/invoicing/autofactura/create - */ -export const createAutofactura = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const input = req.body as AutofacturaRequest; - - // TODO: Implement autofactura - // 1. Lookup order - // 2. Validate it hasn't been invoiced yet - // 3. Create CFDI from order data - - res.status(501).json({ - success: false, - error: 'Autofactura not yet implemented', - }); - } catch (error) { - next(error); - } -}; diff --git a/backend/src/modules/invoicing/controllers/index.ts b/backend/src/modules/invoicing/controllers/index.ts deleted file mode 100644 index a78f683..0000000 --- a/backend/src/modules/invoicing/controllers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './cfdi.controller'; diff --git a/backend/src/modules/invoicing/entities/cfdi-config.entity.ts b/backend/src/modules/invoicing/entities/cfdi-config.entity.ts deleted file mode 100644 index 40d8e61..0000000 --- a/backend/src/modules/invoicing/entities/cfdi-config.entity.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index, -} from 'typeorm'; - -export enum CFDIConfigStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', - PENDING_VALIDATION = 'pending_validation', - EXPIRED = 'expired', -} - -export enum PACProvider { - FINKOK = 'finkok', - SOLUCIONES_FISCALES = 'soluciones_fiscales', - FACTURAPI = 'facturapi', - SW_SAPIEN = 'sw_sapien', - DIGISAT = 'digisat', - OTHER = 'other', -} - -@Entity('cfdi_configs', { schema: 'retail' }) -@Index(['tenantId', 'branchId']) -@Index(['tenantId', 'rfc'], { unique: true }) -@Index(['tenantId', 'status']) -export class CFDIConfig { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid', nullable: true }) - branchId: string; - - @Column({ - type: 'enum', - enum: CFDIConfigStatus, - default: CFDIConfigStatus.PENDING_VALIDATION, - }) - status: CFDIConfigStatus; - - // Emisor (Issuer) Info - @Column({ length: 13 }) - rfc: string; - - @Column({ name: 'razon_social', length: 300 }) - razonSocial: string; - - @Column({ name: 'nombre_comercial', length: 200, nullable: true }) - nombreComercial: string; - - @Column({ name: 'regimen_fiscal', length: 3 }) - regimenFiscal: string; // SAT catalog code - - @Column({ name: 'regimen_fiscal_descripcion', length: 200, nullable: true }) - regimenFiscalDescripcion: string; - - // Address - @Column({ name: 'codigo_postal', length: 5 }) - codigoPostal: string; - - @Column({ type: 'text', nullable: true }) - domicilio: string; - - // PAC Configuration - @Column({ - name: 'pac_provider', - type: 'enum', - enum: PACProvider, - default: PACProvider.FINKOK, - }) - pacProvider: PACProvider; - - @Column({ name: 'pac_username', length: 100, nullable: true }) - pacUsername: string; - - @Column({ name: 'pac_password_encrypted', type: 'text', nullable: true }) - pacPasswordEncrypted: string; - - @Column({ name: 'pac_environment', length: 20, default: 'sandbox' }) - pacEnvironment: 'sandbox' | 'production'; - - @Column({ name: 'pac_api_url', length: 255, nullable: true }) - pacApiUrl: string; - - // Certificate (CSD) - @Column({ name: 'certificate_number', length: 20, nullable: true }) - certificateNumber: string; - - @Column({ name: 'certificate_encrypted', type: 'text', nullable: true }) - certificateEncrypted: string; - - @Column({ name: 'private_key_encrypted', type: 'text', nullable: true }) - privateKeyEncrypted: string; - - @Column({ name: 'certificate_password_encrypted', type: 'text', nullable: true }) - certificatePasswordEncrypted: string; - - @Column({ name: 'certificate_valid_from', type: 'timestamp with time zone', nullable: true }) - certificateValidFrom: Date; - - @Column({ name: 'certificate_valid_until', type: 'timestamp with time zone', nullable: true }) - certificateValidUntil: Date; - - // CFDI defaults - @Column({ name: 'default_serie', length: 10, default: 'A' }) - defaultSerie: string; - - @Column({ name: 'current_folio', type: 'int', default: 1 }) - currentFolio: number; - - @Column({ name: 'default_forma_pago', length: 2, default: '01' }) - defaultFormaPago: string; // 01=Efectivo, 03=Transferencia, etc. - - @Column({ name: 'default_metodo_pago', length: 3, default: 'PUE' }) - defaultMetodoPago: string; // PUE=Pago en Una Exhibición, PPD=Pago en Parcialidades - - @Column({ name: 'default_uso_cfdi', length: 4, default: 'G03' }) - defaultUsoCFDI: string; // G03=Gastos en general - - @Column({ name: 'default_moneda', length: 3, default: 'MXN' }) - defaultMoneda: string; - - // Logo and branding - @Column({ name: 'logo_url', length: 255, nullable: true }) - logoUrl: string; - - @Column({ name: 'pdf_template', length: 50, default: 'standard' }) - pdfTemplate: string; - - @Column({ name: 'footer_text', type: 'text', nullable: true }) - footerText: string; - - // Email settings - @Column({ name: 'send_email_on_issue', type: 'boolean', default: true }) - sendEmailOnIssue: boolean; - - @Column({ name: 'email_from', length: 100, nullable: true }) - emailFrom: string; - - @Column({ name: 'email_subject_template', length: 200, nullable: true }) - emailSubjectTemplate: string; - - @Column({ name: 'email_body_template', type: 'text', nullable: true }) - emailBodyTemplate: string; - - // Notification settings - @Column({ name: 'notify_certificate_expiry', type: 'boolean', default: true }) - notifyCertificateExpiry: boolean; - - @Column({ name: 'notify_days_before_expiry', type: 'int', default: 30 }) - notifyDaysBeforeExpiry: number; - - // Auto-invoicing - @Column({ name: 'auto_invoice_pos', type: 'boolean', default: false }) - autoInvoicePOS: boolean; - - @Column({ name: 'auto_invoice_ecommerce', type: 'boolean', default: true }) - autoInvoiceEcommerce: boolean; - - // Rate limiting - @Column({ name: 'max_invoices_per_day', type: 'int', nullable: true }) - maxInvoicesPerDay: number; - - @Column({ name: 'invoices_issued_today', type: 'int', default: 0 }) - invoicesIssuedToday: number; - - @Column({ name: 'last_invoice_reset', type: 'date', nullable: true }) - lastInvoiceReset: Date; - - // Validation - @Column({ name: 'last_validated_at', type: 'timestamp with time zone', nullable: true }) - lastValidatedAt: Date; - - @Column({ name: 'validation_errors', type: 'jsonb', nullable: true }) - validationErrors: string[]; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy: string; -} diff --git a/backend/src/modules/invoicing/entities/cfdi.entity.ts b/backend/src/modules/invoicing/entities/cfdi.entity.ts deleted file mode 100644 index 356779e..0000000 --- a/backend/src/modules/invoicing/entities/cfdi.entity.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index, -} from 'typeorm'; - -export enum CFDIStatus { - DRAFT = 'draft', - PENDING = 'pending', - STAMPED = 'stamped', - CANCELLED = 'cancelled', - CANCELLATION_PENDING = 'cancellation_pending', - ERROR = 'error', -} - -export enum CFDIType { - INGRESO = 'I', // Income (sale invoice) - EGRESO = 'E', // Expense (credit note) - TRASLADO = 'T', // Transfer - NOMINA = 'N', // Payroll - PAGO = 'P', // Payment receipt -} - -@Entity('cfdis', { schema: 'retail' }) -@Index(['tenantId', 'status', 'fechaEmision']) -@Index(['tenantId', 'uuid'], { unique: true }) -@Index(['tenantId', 'serie', 'folio'], { unique: true }) -@Index(['tenantId', 'orderId']) -@Index(['tenantId', 'receptorRfc']) -export class CFDI { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'config_id', type: 'uuid' }) - configId: string; - - @Column({ name: 'branch_id', type: 'uuid', nullable: true }) - branchId: string; - - @Column({ - type: 'enum', - enum: CFDIStatus, - default: CFDIStatus.DRAFT, - }) - status: CFDIStatus; - - @Column({ - name: 'tipo_comprobante', - type: 'enum', - enum: CFDIType, - default: CFDIType.INGRESO, - }) - tipoComprobante: CFDIType; - - // CFDI identification - @Column({ type: 'uuid', nullable: true }) - uuid: string; // UUID assigned by SAT after stamping - - @Column({ length: 10 }) - serie: string; - - @Column({ type: 'int' }) - folio: number; - - @Column({ name: 'fecha_emision', type: 'timestamp with time zone' }) - fechaEmision: Date; - - @Column({ name: 'fecha_timbrado', type: 'timestamp with time zone', nullable: true }) - fechaTimbrado: Date; - - // Emisor (Issuer) - @Column({ name: 'emisor_rfc', length: 13 }) - emisorRfc: string; - - @Column({ name: 'emisor_nombre', length: 300 }) - emisorNombre: string; - - @Column({ name: 'emisor_regimen_fiscal', length: 3 }) - emisorRegimenFiscal: string; - - // Receptor (Receiver) - @Column({ name: 'receptor_rfc', length: 13 }) - receptorRfc: string; - - @Column({ name: 'receptor_nombre', length: 300 }) - receptorNombre: string; - - @Column({ name: 'receptor_uso_cfdi', length: 4 }) - receptorUsoCFDI: string; - - @Column({ name: 'receptor_regimen_fiscal', length: 3, nullable: true }) - receptorRegimenFiscal: string; - - @Column({ name: 'receptor_domicilio_fiscal', length: 5, nullable: true }) - receptorDomicilioFiscal: string; - - @Column({ name: 'receptor_email', length: 100, nullable: true }) - receptorEmail: string; - - // Amounts - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - descuento: number; - - @Column({ name: 'total_impuestos_trasladados', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalImpuestosTrasladados: number; - - @Column({ name: 'total_impuestos_retenidos', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalImpuestosRetenidos: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - // Payment info - @Column({ name: 'forma_pago', length: 2 }) - formaPago: string; - - @Column({ name: 'metodo_pago', length: 3 }) - metodoPago: string; - - @Column({ length: 3, default: 'MXN' }) - moneda: string; - - @Column({ name: 'tipo_cambio', type: 'decimal', precision: 10, scale: 6, default: 1 }) - tipoCambio: number; - - @Column({ name: 'condiciones_pago', length: 100, nullable: true }) - condicionesPago: string; - - // Concepts (line items) - @Column({ type: 'jsonb' }) - conceptos: { - claveProdServ: string; - claveUnidad: string; - cantidad: number; - unidad: string; - descripcion: string; - valorUnitario: number; - importe: number; - descuento?: number; - objetoImp: string; - impuestos?: { - traslados?: { - base: number; - impuesto: string; - tipoFactor: string; - tasaOCuota: number; - importe: number; - }[]; - retenciones?: { - base: number; - impuesto: string; - tipoFactor: string; - tasaOCuota: number; - importe: number; - }[]; - }; - // Retail-specific - productId?: string; - orderLineId?: string; - }[]; - - // Tax summary - @Column({ name: 'impuestos', type: 'jsonb', nullable: true }) - impuestos: { - traslados?: { - impuesto: string; - tipoFactor: string; - tasaOCuota: number; - importe: number; - base: number; - }[]; - retenciones?: { - impuesto: string; - importe: number; - }[]; - }; - - // Related CFDIs - @Column({ name: 'cfdi_relacionados', type: 'jsonb', nullable: true }) - cfdiRelacionados: { - tipoRelacion: string; - uuids: string[]; - }; - - // Seal and stamps - @Column({ name: 'sello_cfdi', type: 'text', nullable: true }) - selloCFDI: string; - - @Column({ name: 'sello_sat', type: 'text', nullable: true }) - selloSAT: string; - - @Column({ name: 'certificado_sat', length: 20, nullable: true }) - certificadoSAT: string; - - @Column({ name: 'cadena_original_sat', type: 'text', nullable: true }) - cadenaOriginalSAT: string; - - // XML content - @Column({ name: 'xml_content', type: 'text', nullable: true }) - xmlContent: string; - - // PDF - @Column({ name: 'pdf_url', length: 255, nullable: true }) - pdfUrl: string; - - @Column({ name: 'pdf_generated_at', type: 'timestamp with time zone', nullable: true }) - pdfGeneratedAt: Date; - - // QR code data - @Column({ name: 'qr_data', type: 'text', nullable: true }) - qrData: string; - - // Reference to original order - @Column({ name: 'order_id', type: 'uuid', nullable: true }) - orderId: string; - - @Column({ name: 'order_number', length: 30, nullable: true }) - orderNumber: string; - - @Column({ name: 'order_type', length: 20, nullable: true }) - orderType: 'pos' | 'ecommerce'; - - // Customer reference - @Column({ name: 'customer_id', type: 'uuid', nullable: true }) - customerId: string; - - // Cancellation - @Column({ name: 'cancelled_at', type: 'timestamp with time zone', nullable: true }) - cancelledAt: Date; - - @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) - cancelledBy: string; - - @Column({ name: 'cancellation_reason', length: 2, nullable: true }) - cancellationReason: string; // SAT cancellation code - - @Column({ name: 'cancellation_uuid', type: 'uuid', nullable: true }) - cancellationUuid: string; - - @Column({ name: 'cancellation_status', length: 50, nullable: true }) - cancellationStatus: string; - - @Column({ name: 'substitute_uuid', type: 'uuid', nullable: true }) - substituteUuid: string; - - // Email - @Column({ name: 'email_sent', type: 'boolean', default: false }) - emailSent: boolean; - - @Column({ name: 'email_sent_at', type: 'timestamp with time zone', nullable: true }) - emailSentAt: Date; - - @Column({ name: 'email_sent_to', length: 100, nullable: true }) - emailSentTo: string; - - // PAC response - @Column({ name: 'pac_response', type: 'jsonb', nullable: true }) - pacResponse: Record; - - // Error info - @Column({ name: 'error_code', length: 20, nullable: true }) - errorCode: string; - - @Column({ name: 'error_message', type: 'text', nullable: true }) - errorMessage: string; - - // User - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} diff --git a/backend/src/modules/invoicing/entities/index.ts b/backend/src/modules/invoicing/entities/index.ts deleted file mode 100644 index c28478c..0000000 --- a/backend/src/modules/invoicing/entities/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './cfdi-config.entity'; -export * from './cfdi.entity'; diff --git a/backend/src/modules/invoicing/index.ts b/backend/src/modules/invoicing/index.ts deleted file mode 100644 index fd9152d..0000000 --- a/backend/src/modules/invoicing/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Services -export * from './services'; - -// Controllers -export * from './controllers'; - -// Routes -export * from './routes'; - -// Validation -export * from './validation'; - -// Entities -export * from './entities'; diff --git a/backend/src/modules/invoicing/routes/cfdi.routes.ts b/backend/src/modules/invoicing/routes/cfdi.routes.ts deleted file mode 100644 index 45302e0..0000000 --- a/backend/src/modules/invoicing/routes/cfdi.routes.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { Router } from 'express'; -import { authMiddleware } from '../../auth/middleware/auth.middleware'; -import { branchMiddleware } from '../../branches/middleware/branch.middleware'; -import { validateRequest } from '../../../shared/middleware/validation.middleware'; -import { requirePermissions } from '../../../shared/middleware/permissions.middleware'; -import { - createCFDISchema, - cancelCFDISchema, - listCFDIsQuerySchema, - resendEmailSchema, - createCreditNoteSchema, - createCFDIConfigSchema, - updateCFDIConfigSchema, - validateCFDIConfigSchema, - autofacturaRequestSchema, - lookupOrderSchema, - cfdiStatsQuerySchema, -} from '../validation/cfdi.schema'; -import * as cfdiController from '../controllers/cfdi.controller'; - -const router = Router(); - -// ==================== PUBLIC ROUTES (Autofactura) ==================== - -/** - * Lookup order for autofactura (public) - */ -router.post( - '/autofactura/lookup', - validateRequest(lookupOrderSchema), - cfdiController.lookupOrder -); - -/** - * Create autofactura (public) - */ -router.post( - '/autofactura/create', - validateRequest(autofacturaRequestSchema), - cfdiController.createAutofactura -); - -/** - * Get UsoCFDI options (public for forms) - */ -router.get('/uso-cfdi-options', cfdiController.getUsoCFDIOptions); - -/** - * Get common SAT product codes (public for forms) - */ -router.get('/claves-prod-serv', cfdiController.getClavesProdServ); - -/** - * Get cancellation reasons (public for forms) - */ -router.get('/cancellation-reasons', cfdiController.getCancellationReasons); - -// ==================== AUTHENTICATED ROUTES ==================== - -// All routes below require authentication -router.use(authMiddleware); - -// ==================== CFDI ROUTES ==================== - -/** - * List CFDIs with filters - */ -router.get( - '/cfdis', - validateRequest(listCFDIsQuerySchema, 'query'), - requirePermissions('cfdi:view'), - cfdiController.listCFDIs -); - -/** - * Get CFDI statistics - */ -router.get( - '/stats', - validateRequest(cfdiStatsQuerySchema, 'query'), - requirePermissions('cfdi:view'), - cfdiController.getCFDIStats -); - -/** - * Get CFDI by ID - */ -router.get( - '/cfdis/:id', - requirePermissions('cfdi:view'), - cfdiController.getCFDI -); - -/** - * Get CFDI by UUID - */ -router.get( - '/cfdis/uuid/:uuid', - requirePermissions('cfdi:view'), - cfdiController.getCFDIByUUID -); - -/** - * Create a new CFDI - */ -router.post( - '/cfdis', - branchMiddleware, - validateRequest(createCFDISchema), - requirePermissions('cfdi:create'), - cfdiController.createCFDI -); - -/** - * Stamp a draft CFDI - */ -router.post( - '/cfdis/:id/stamp', - requirePermissions('cfdi:stamp'), - cfdiController.stampCFDI -); - -/** - * Cancel a CFDI - */ -router.post( - '/cfdis/:id/cancel', - validateRequest(cancelCFDISchema), - requirePermissions('cfdi:cancel'), - cfdiController.cancelCFDI -); - -/** - * Verify CFDI status with SAT - */ -router.get( - '/cfdis/:id/verify', - requirePermissions('cfdi:view'), - cfdiController.verifyCFDI -); - -/** - * Download CFDI XML - */ -router.get( - '/cfdis/:id/xml', - requirePermissions('cfdi:view'), - cfdiController.downloadXML -); - -/** - * Resend CFDI via email - */ -router.post( - '/cfdis/:id/resend-email', - validateRequest(resendEmailSchema), - requirePermissions('cfdi:send'), - cfdiController.resendEmail -); - -/** - * Create credit note for a CFDI - */ -router.post( - '/cfdis/:id/credit-note', - branchMiddleware, - validateRequest(createCreditNoteSchema.omit({ originalCfdiId: true })), - requirePermissions('cfdi:create'), - cfdiController.createCreditNote -); - -// ==================== CONFIG ROUTES ==================== - -/** - * Get active CFDI configuration - */ -router.get( - '/config', - requirePermissions('cfdi:config:view'), - cfdiController.getConfig -); - -/** - * Create or update CFDI configuration - */ -router.post( - '/config', - validateRequest(createCFDIConfigSchema), - requirePermissions('cfdi:config:manage'), - cfdiController.upsertConfig -); - -/** - * Update CFDI configuration - */ -router.put( - '/config', - validateRequest(updateCFDIConfigSchema), - requirePermissions('cfdi:config:manage'), - cfdiController.upsertConfig -); - -/** - * Upload certificate (CSD) - */ -router.post( - '/config/certificate', - requirePermissions('cfdi:config:manage'), - cfdiController.uploadCertificate -); - -/** - * Validate CFDI configuration - */ -router.post( - '/config/validate', - validateRequest(validateCFDIConfigSchema), - requirePermissions('cfdi:config:view'), - cfdiController.validateConfig -); - -export default router; diff --git a/backend/src/modules/invoicing/routes/index.ts b/backend/src/modules/invoicing/routes/index.ts deleted file mode 100644 index 240ad01..0000000 --- a/backend/src/modules/invoicing/routes/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as cfdiRoutes } from './cfdi.routes'; diff --git a/backend/src/modules/invoicing/services/cfdi-builder.service.ts b/backend/src/modules/invoicing/services/cfdi-builder.service.ts deleted file mode 100644 index d83f3ef..0000000 --- a/backend/src/modules/invoicing/services/cfdi-builder.service.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { CFDIConfig } from '../entities/cfdi-config.entity'; -import { CFDIType } from '../entities/cfdi.entity'; -import { CFDIData, CFDIConcepto, CFDIImpuestos, XMLService } from './xml.service'; - -export interface OrderLineData { - productId?: string; - productCode: string; - productName: string; - claveProdServ: string; // SAT product/service code - claveUnidad: string; // SAT unit code - unidad: string; // Unit description - quantity: number; - unitPrice: number; - discount?: number; - taxRate: number; // e.g., 0.16 for 16% IVA - taxIncluded?: boolean; // Whether price includes tax - retentionIVA?: number; // IVA retention rate (e.g., 0.106667) - retentionISR?: number; // ISR retention rate - objetoImp?: string; // Tax object code (01, 02, 03) -} - -export interface ReceptorData { - rfc: string; - nombre: string; - usoCFDI: string; - regimenFiscal?: string; - domicilioFiscal?: string; - email?: string; -} - -export interface CFDIBuildInput { - config: CFDIConfig; - receptor: ReceptorData; - lines: OrderLineData[]; - formaPago: string; - metodoPago: string; - moneda?: string; - tipoCambio?: number; - condicionesPago?: string; - tipoComprobante?: CFDIType; - cfdiRelacionados?: { - tipoRelacion: string; - uuids: string[]; - }; - orderId?: string; - orderNumber?: string; - orderType?: 'pos' | 'ecommerce'; - notes?: string; -} - -export interface CFDIBuildResult { - cfdiData: CFDIData; - subtotal: number; - descuento: number; - totalImpuestosTrasladados: number; - totalImpuestosRetenidos: number; - total: number; - conceptos: CFDIConcepto[]; -} - -// SAT Tax Types -const TAX_IVA = '002'; -const TAX_ISR = '001'; -const TAX_IEPS = '003'; - -// SAT Tax Factor Types -const FACTOR_TASA = 'Tasa'; -const FACTOR_CUOTA = 'Cuota'; -const FACTOR_EXENTO = 'Exento'; - -// SAT Object of Tax Codes -const OBJETO_IMP = { - NO_OBJETO: '01', // Not subject to tax - SI_OBJETO: '02', // Subject to tax - SI_NO_DESGLOSE: '03', // Subject to tax but no breakdown -}; - -export class CFDIBuilderService { - private xmlService: XMLService; - - constructor() { - this.xmlService = new XMLService(); - } - - /** - * Build complete CFDI data structure from order data - */ - buildFromOrder(input: CFDIBuildInput): CFDIBuildResult { - const { config, receptor, lines, tipoComprobante = CFDIType.INGRESO } = input; - - // Process each line to calculate taxes - const processedLines = lines.map(line => this.processLine(line)); - - // Calculate totals - const subtotal = processedLines.reduce((sum, l) => sum + l.importe, 0); - const descuento = processedLines.reduce((sum, l) => sum + (l.descuento || 0), 0); - - // Aggregate taxes - const aggregatedTaxes = this.aggregateTaxes(processedLines); - const totalImpuestosTrasladados = aggregatedTaxes.traslados.reduce((sum, t) => sum + t.importe, 0); - const totalImpuestosRetenidos = aggregatedTaxes.retenciones.reduce((sum, r) => sum + r.importe, 0); - - const total = subtotal - descuento + totalImpuestosTrasladados - totalImpuestosRetenidos; - - // Build CFDI data structure - const cfdiData: CFDIData = { - version: '4.0', - serie: config.defaultSerie, - folio: config.currentFolio, - fecha: this.xmlService.formatCFDIDate(new Date()), - formaPago: input.formaPago || config.defaultFormaPago, - metodoPago: input.metodoPago || config.defaultMetodoPago, - tipoDeComprobante: tipoComprobante, - exportacion: '01', // No exportación - lugarExpedicion: config.codigoPostal, - moneda: input.moneda || config.defaultMoneda, - tipoCambio: input.tipoCambio, - subTotal: this.round(subtotal), - descuento: descuento > 0 ? this.round(descuento) : undefined, - total: this.round(total), - condicionesDePago: input.condicionesPago, - emisor: { - rfc: config.rfc, - nombre: config.razonSocial, - regimenFiscal: config.regimenFiscal, - }, - receptor: { - rfc: receptor.rfc, - nombre: receptor.nombre, - usoCFDI: receptor.usoCFDI || config.defaultUsoCFDI, - domicilioFiscalReceptor: receptor.domicilioFiscal, - regimenFiscalReceptor: receptor.regimenFiscal, - }, - conceptos: processedLines, - impuestos: aggregatedTaxes.traslados.length > 0 || aggregatedTaxes.retenciones.length > 0 - ? { - traslados: aggregatedTaxes.traslados.length > 0 ? aggregatedTaxes.traslados : undefined, - retenciones: aggregatedTaxes.retenciones.length > 0 ? aggregatedTaxes.retenciones : undefined, - } - : undefined, - cfdiRelacionados: input.cfdiRelacionados, - }; - - return { - cfdiData, - subtotal: this.round(subtotal), - descuento: this.round(descuento), - totalImpuestosTrasladados: this.round(totalImpuestosTrasladados), - totalImpuestosRetenidos: this.round(totalImpuestosRetenidos), - total: this.round(total), - conceptos: processedLines, - }; - } - - /** - * Build credit note (Nota de Crédito) CFDI - */ - buildCreditNote( - config: CFDIConfig, - receptor: ReceptorData, - originalUuid: string, - lines: OrderLineData[], - reason: string - ): CFDIBuildResult { - const result = this.buildFromOrder({ - config, - receptor, - lines, - formaPago: '99', // Por definir - metodoPago: 'PUE', - tipoComprobante: CFDIType.EGRESO, - cfdiRelacionados: { - tipoRelacion: '01', // Nota de crédito de los documentos relacionados - uuids: [originalUuid], - }, - }); - - return result; - } - - /** - * Build payment complement CFDI (Complemento de Pago) - */ - buildPaymentComplement( - config: CFDIConfig, - receptor: ReceptorData, - relatedCfdis: Array<{ - uuid: string; - serie: string; - folio: string; - total: number; - saldoAnterior: number; - importePagado: number; - saldoInsoluto: number; - }>, - paymentData: { - fecha: Date; - formaPago: string; - moneda: string; - tipoCambio?: number; - monto: number; - numOperacion?: string; - rfcBancoEmisor?: string; - ctaOrdenante?: string; - rfcBancoBeneficiario?: string; - ctaBeneficiario?: string; - } - ): CFDIData { - // Payment CFDIs have special structure - const cfdiData: CFDIData = { - version: '4.0', - serie: config.defaultSerie, - folio: config.currentFolio, - fecha: this.xmlService.formatCFDIDate(new Date()), - formaPago: '', // Not used in payment CFDI - metodoPago: '', // Not used - tipoDeComprobante: CFDIType.PAGO, - exportacion: '01', - lugarExpedicion: config.codigoPostal, - moneda: 'XXX', // Required for payment CFDIs - subTotal: 0, - total: 0, - emisor: { - rfc: config.rfc, - nombre: config.razonSocial, - regimenFiscal: config.regimenFiscal, - }, - receptor: { - rfc: receptor.rfc, - nombre: receptor.nombre, - usoCFDI: 'CP01', // Por definir - mandatory for payments - domicilioFiscalReceptor: receptor.domicilioFiscal, - regimenFiscalReceptor: receptor.regimenFiscal, - }, - conceptos: [{ - claveProdServ: '84111506', // Servicios de facturación - claveUnidad: 'ACT', // Actividad - cantidad: 1, - unidad: 'Actividad', - descripcion: 'Pago', - valorUnitario: 0, - importe: 0, - objetoImp: '01', - }], - }; - - // Note: The actual payment complement (Pagos 2.0) would be added as a Complemento - // This requires additional XML structure beyond basic CFDI - - return cfdiData; - } - - /** - * Process a single line item to CFDI concepto - */ - private processLine(line: OrderLineData): CFDIConcepto { - let valorUnitario: number; - let importe: number; - let taxBase: number; - - if (line.taxIncluded) { - // If price includes tax, we need to extract the base price - const taxDivisor = 1 + line.taxRate; - valorUnitario = line.unitPrice / taxDivisor; - importe = valorUnitario * line.quantity; - } else { - valorUnitario = line.unitPrice; - importe = valorUnitario * line.quantity; - } - - // Apply discount if any - const descuento = line.discount || 0; - taxBase = importe - descuento; - - // Build tax structure - const traslados: CFDIConcepto['impuestos']['traslados'] = []; - const retenciones: CFDIConcepto['impuestos']['retenciones'] = []; - - // Determine object of tax - const objetoImp = line.objetoImp || (line.taxRate > 0 ? OBJETO_IMP.SI_OBJETO : OBJETO_IMP.NO_OBJETO); - - // Only add taxes if subject to tax - if (objetoImp === OBJETO_IMP.SI_OBJETO) { - // IVA traslado - if (line.taxRate > 0) { - traslados.push({ - base: this.round(taxBase), - impuesto: TAX_IVA, - tipoFactor: FACTOR_TASA, - tasaOCuota: line.taxRate, - importe: this.round(taxBase * line.taxRate), - }); - } else if (line.taxRate === 0) { - // Tasa 0% - traslados.push({ - base: this.round(taxBase), - impuesto: TAX_IVA, - tipoFactor: FACTOR_TASA, - tasaOCuota: 0, - importe: 0, - }); - } - - // IVA retention - if (line.retentionIVA && line.retentionIVA > 0) { - retenciones.push({ - base: this.round(taxBase), - impuesto: TAX_IVA, - tipoFactor: FACTOR_TASA, - tasaOCuota: line.retentionIVA, - importe: this.round(taxBase * line.retentionIVA), - }); - } - - // ISR retention - if (line.retentionISR && line.retentionISR > 0) { - retenciones.push({ - base: this.round(taxBase), - impuesto: TAX_ISR, - tipoFactor: FACTOR_TASA, - tasaOCuota: line.retentionISR, - importe: this.round(taxBase * line.retentionISR), - }); - } - } - - return { - claveProdServ: line.claveProdServ, - claveUnidad: line.claveUnidad, - cantidad: line.quantity, - unidad: line.unidad, - descripcion: line.productName, - valorUnitario: this.round(valorUnitario), - importe: this.round(importe), - descuento: descuento > 0 ? this.round(descuento) : undefined, - objetoImp, - impuestos: (traslados.length > 0 || retenciones.length > 0) ? { - traslados: traslados.length > 0 ? traslados : undefined, - retenciones: retenciones.length > 0 ? retenciones : undefined, - } : undefined, - }; - } - - /** - * Aggregate taxes from all conceptos - */ - private aggregateTaxes(conceptos: CFDIConcepto[]): CFDIImpuestos { - const trasladosMap = new Map(); - - const retencionesMap = new Map(); - - for (const concepto of conceptos) { - if (!concepto.impuestos) continue; - - // Aggregate traslados - if (concepto.impuestos.traslados) { - for (const t of concepto.impuestos.traslados) { - const key = `${t.impuesto}-${t.tipoFactor}-${t.tasaOCuota}`; - const existing = trasladosMap.get(key); - if (existing) { - existing.importe += t.importe; - existing.base += t.base; - } else { - trasladosMap.set(key, { - impuesto: t.impuesto, - tipoFactor: t.tipoFactor, - tasaOCuota: t.tasaOCuota, - importe: t.importe, - base: t.base, - }); - } - } - } - - // Aggregate retenciones - if (concepto.impuestos.retenciones) { - for (const r of concepto.impuestos.retenciones) { - const key = r.impuesto; - const existing = retencionesMap.get(key); - if (existing) { - existing.importe += r.importe; - } else { - retencionesMap.set(key, { - impuesto: r.impuesto, - importe: r.importe, - }); - } - } - } - } - - return { - traslados: Array.from(trasladosMap.values()).map(t => ({ - ...t, - importe: this.round(t.importe), - base: this.round(t.base), - })), - retenciones: Array.from(retencionesMap.values()).map(r => ({ - ...r, - importe: this.round(r.importe), - })), - }; - } - - /** - * Validate receptor data for CFDI 4.0 requirements - */ - validateReceptor(receptor: ReceptorData): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - // RFC validation - if (!receptor.rfc || receptor.rfc.length < 12 || receptor.rfc.length > 13) { - errors.push('RFC must be 12 characters (persona moral) or 13 characters (persona física)'); - } - - // For CFDI 4.0, if not generic RFC, need additional fields - if (receptor.rfc !== 'XAXX010101000' && receptor.rfc !== 'XEXX010101000') { - if (!receptor.regimenFiscal) { - errors.push('Régimen fiscal is required for non-generic RFC'); - } - if (!receptor.domicilioFiscal) { - errors.push('Domicilio fiscal (código postal) is required for non-generic RFC'); - } - } - - if (!receptor.nombre) { - errors.push('Receptor name is required'); - } - - if (!receptor.usoCFDI) { - errors.push('Uso de CFDI is required'); - } - - return { - valid: errors.length === 0, - errors, - }; - } - - /** - * Get valid UsoCFDI options based on receptor type - */ - getUsoCFDIOptions(isPersonaFisica: boolean): Array<{ code: string; description: string }> { - const common = [ - { code: 'G01', description: 'Adquisición de mercancías' }, - { code: 'G02', description: 'Devoluciones, descuentos o bonificaciones' }, - { code: 'G03', description: 'Gastos en general' }, - ]; - - const personaFisica = [ - { code: 'D01', description: 'Honorarios médicos, dentales y gastos hospitalarios' }, - { code: 'D02', description: 'Gastos médicos por incapacidad o discapacidad' }, - { code: 'D03', description: 'Gastos funerales' }, - { code: 'D04', description: 'Donativos' }, - { code: 'D05', description: 'Intereses reales efectivamente pagados por créditos hipotecarios' }, - { code: 'D06', description: 'Aportaciones voluntarias al SAR' }, - { code: 'D07', description: 'Primas por seguros de gastos médicos' }, - { code: 'D08', description: 'Gastos de transportación escolar obligatoria' }, - { code: 'D09', description: 'Depósitos en cuentas para el ahorro, primas de pensiones' }, - { code: 'D10', description: 'Pagos por servicios educativos (colegiaturas)' }, - ]; - - const publicoGeneral = [ - { code: 'S01', description: 'Sin efectos fiscales' }, - ]; - - return isPersonaFisica - ? [...common, ...personaFisica, ...publicoGeneral] - : [...common, ...publicoGeneral]; - } - - /** - * Get common SAT product/service codes for retail - */ - getCommonClavesProdServ(): Array<{ code: string; description: string }> { - return [ - { code: '01010101', description: 'No existe en el catálogo' }, - { code: '43231500', description: 'Software funcional específico de la empresa' }, - { code: '43232600', description: 'Servicios de datos' }, - { code: '50000000', description: 'Alimentos, bebidas y tabaco' }, - { code: '53000000', description: 'Prendas de vestir, calzado y accesorios' }, - { code: '42000000', description: 'Equipos y suministros médicos' }, - { code: '44000000', description: 'Equipos de oficina, accesorios y suministros' }, - { code: '52000000', description: 'Muebles y accesorios' }, - { code: '78180000', description: 'Servicios de mantenimiento y reparación de equipos' }, - { code: '80000000', description: 'Servicios de gestión, servicios profesionales de empresa' }, - { code: '84110000', description: 'Servicios contables' }, - { code: '92000000', description: 'Servicios de defensa y orden público' }, - ]; - } - - /** - * Round to 2 decimal places for money values - */ - private round(value: number): number { - return Math.round(value * 100) / 100; - } -} diff --git a/backend/src/modules/invoicing/services/cfdi.service.ts b/backend/src/modules/invoicing/services/cfdi.service.ts deleted file mode 100644 index f64878a..0000000 --- a/backend/src/modules/invoicing/services/cfdi.service.ts +++ /dev/null @@ -1,815 +0,0 @@ -import { DataSource, Repository, QueryRunner } from 'typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { CFDI, CFDIStatus, CFDIType } from '../entities/cfdi.entity'; -import { CFDIConfig, CFDIConfigStatus } from '../entities/cfdi-config.entity'; -import { XMLService } from './xml.service'; -import { PACService, CANCELLATION_REASONS } from './pac.service'; -import { CFDIBuilderService, OrderLineData, ReceptorData } from './cfdi-builder.service'; - -export interface ServiceResult { - success: boolean; - data?: T; - error?: string; - errors?: string[]; -} - -export interface CreateCFDIInput { - configId?: string; - branchId?: string; - receptor: ReceptorData; - lines: OrderLineData[]; - formaPago: string; - metodoPago: string; - moneda?: string; - tipoCambio?: number; - condicionesPago?: string; - orderId?: string; - orderNumber?: string; - orderType?: 'pos' | 'ecommerce'; - notes?: string; - stampImmediately?: boolean; -} - -export interface CFDIListQuery { - page?: number; - limit?: number; - status?: CFDIStatus; - startDate?: Date; - endDate?: Date; - receptorRfc?: string; - branchId?: string; - search?: string; -} - -export class CFDIService extends BaseService { - private xmlService: XMLService; - private pacService: PACService; - private builderService: CFDIBuilderService; - private configRepository: Repository; - - constructor( - dataSource: DataSource, - repository: Repository - ) { - super(dataSource, repository); - this.xmlService = new XMLService(); - this.pacService = new PACService(); - this.builderService = new CFDIBuilderService(); - this.configRepository = dataSource.getRepository(CFDIConfig); - } - - /** - * Get active CFDI configuration for tenant - */ - async getActiveConfig(tenantId: string, branchId?: string): Promise> { - try { - const whereClause: any = { - tenantId, - status: CFDIConfigStatus.ACTIVE, - }; - - // Try branch-specific config first - if (branchId) { - const branchConfig = await this.configRepository.findOne({ - where: { ...whereClause, branchId }, - }); - if (branchConfig) { - return { success: true, data: branchConfig }; - } - } - - // Fall back to tenant-level config - const tenantConfig = await this.configRepository.findOne({ - where: { ...whereClause, branchId: null as any }, - }); - - if (!tenantConfig) { - return { - success: false, - error: 'No active CFDI configuration found. Please configure invoicing first.', - }; - } - - return { success: true, data: tenantConfig }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get CFDI config', - }; - } - } - - /** - * Create and optionally stamp a CFDI - */ - async createCFDI( - tenantId: string, - input: CreateCFDIInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - // Get config - const configResult = await this.getActiveConfig(tenantId, input.branchId); - if (!configResult.success || !configResult.data) { - throw new Error(configResult.error || 'No CFDI config'); - } - const config = configResult.data; - - // Validate receptor - const validation = this.builderService.validateReceptor(input.receptor); - if (!validation.valid) { - return { - success: false, - error: 'Invalid receptor data', - errors: validation.errors, - }; - } - - // Build CFDI data - const buildResult = this.builderService.buildFromOrder({ - config, - receptor: input.receptor, - lines: input.lines, - formaPago: input.formaPago, - metodoPago: input.metodoPago, - moneda: input.moneda, - tipoCambio: input.tipoCambio, - condicionesPago: input.condicionesPago, - orderId: input.orderId, - orderNumber: input.orderNumber, - orderType: input.orderType, - notes: input.notes, - }); - - // Create CFDI record - const cfdi = queryRunner.manager.create(CFDI, { - tenantId, - configId: config.id, - branchId: input.branchId, - status: CFDIStatus.DRAFT, - tipoComprobante: CFDIType.INGRESO, - serie: config.defaultSerie, - folio: config.currentFolio, - fechaEmision: new Date(), - emisorRfc: config.rfc, - emisorNombre: config.razonSocial, - emisorRegimenFiscal: config.regimenFiscal, - receptorRfc: input.receptor.rfc, - receptorNombre: input.receptor.nombre, - receptorUsoCFDI: input.receptor.usoCFDI, - receptorRegimenFiscal: input.receptor.regimenFiscal, - receptorDomicilioFiscal: input.receptor.domicilioFiscal, - receptorEmail: input.receptor.email, - subtotal: buildResult.subtotal, - descuento: buildResult.descuento, - totalImpuestosTrasladados: buildResult.totalImpuestosTrasladados, - totalImpuestosRetenidos: buildResult.totalImpuestosRetenidos, - total: buildResult.total, - formaPago: input.formaPago, - metodoPago: input.metodoPago, - moneda: input.moneda || config.defaultMoneda, - tipoCambio: input.tipoCambio || 1, - condicionesPago: input.condicionesPago, - conceptos: buildResult.conceptos, - impuestos: buildResult.cfdiData.impuestos, - orderId: input.orderId, - orderNumber: input.orderNumber, - orderType: input.orderType, - notes: input.notes, - createdBy: userId, - }); - - await queryRunner.manager.save(cfdi); - - // Increment folio - await queryRunner.manager.increment( - CFDIConfig, - { id: config.id }, - 'currentFolio', - 1 - ); - - await queryRunner.commitTransaction(); - - // Stamp if requested - if (input.stampImmediately) { - const stampResult = await this.stampCFDI(tenantId, cfdi.id, userId); - if (stampResult.success && stampResult.data) { - return stampResult; - } - // If stamp failed, still return the draft CFDI - return { - success: true, - data: cfdi, - error: stampResult.error, - }; - } - - return { success: true, data: cfdi }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to create CFDI', - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Stamp a draft CFDI with the PAC - */ - async stampCFDI( - tenantId: string, - cfdiId: string, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - // Get CFDI - const cfdi = await queryRunner.manager.findOne(CFDI, { - where: { id: cfdiId, tenantId }, - }); - - if (!cfdi) { - throw new Error('CFDI not found'); - } - - if (cfdi.status !== CFDIStatus.DRAFT) { - throw new Error(`Cannot stamp CFDI in ${cfdi.status} status`); - } - - // Get config - const config = await queryRunner.manager.findOne(CFDIConfig, { - where: { id: cfdi.configId }, - }); - - if (!config) { - throw new Error('CFDI config not found'); - } - - // Update status to pending - cfdi.status = CFDIStatus.PENDING; - await queryRunner.manager.save(cfdi); - - // Build XML - const cfdiData = { - version: '4.0', - serie: cfdi.serie, - folio: cfdi.folio, - fecha: this.xmlService.formatCFDIDate(cfdi.fechaEmision), - formaPago: cfdi.formaPago, - metodoPago: cfdi.metodoPago, - tipoDeComprobante: cfdi.tipoComprobante, - exportacion: '01', - lugarExpedicion: config.codigoPostal, - moneda: cfdi.moneda, - tipoCambio: cfdi.tipoCambio !== 1 ? cfdi.tipoCambio : undefined, - subTotal: cfdi.subtotal, - descuento: cfdi.descuento > 0 ? cfdi.descuento : undefined, - total: cfdi.total, - condicionesDePago: cfdi.condicionesPago, - emisor: { - rfc: cfdi.emisorRfc, - nombre: cfdi.emisorNombre, - regimenFiscal: cfdi.emisorRegimenFiscal, - }, - receptor: { - rfc: cfdi.receptorRfc, - nombre: cfdi.receptorNombre, - usoCFDI: cfdi.receptorUsoCFDI, - domicilioFiscalReceptor: cfdi.receptorDomicilioFiscal, - regimenFiscalReceptor: cfdi.receptorRegimenFiscal, - }, - conceptos: cfdi.conceptos, - impuestos: cfdi.impuestos, - cfdiRelacionados: cfdi.cfdiRelacionados, - }; - - // Build unsigned XML - const unsignedXml = this.xmlService.buildCFDIXML(cfdiData, config.certificateNumber || ''); - - // Build cadena original - const cadenaOriginal = this.xmlService.buildCadenaOriginal(cfdiData, config.certificateNumber || ''); - - // Load certificate and sign - const certData = await this.pacService.loadCertificate(config); - const sello = await this.pacService.signData(cadenaOriginal, certData.privateKey, certData.password); - - // Add seal and certificate to XML - let signedXml = this.xmlService.addSealToXML(unsignedXml, sello); - signedXml = this.xmlService.addCertificateToXML(signedXml, this.pacService.getCertificateBase64(certData.certificate)); - - // Stamp with PAC - const stampResult = await this.pacService.stampCFDI(config, signedXml); - - if (!stampResult.success) { - cfdi.status = CFDIStatus.ERROR; - cfdi.errorCode = stampResult.errorCode; - cfdi.errorMessage = stampResult.errorMessage; - await queryRunner.manager.save(cfdi); - await queryRunner.commitTransaction(); - - return { - success: false, - data: cfdi, - error: `Stamping failed: ${stampResult.errorMessage}`, - }; - } - - // Parse stamped XML - const stampedData = this.xmlService.parseStampedXML(stampResult.xml!); - - // Update CFDI with stamp data - cfdi.status = CFDIStatus.STAMPED; - cfdi.uuid = stampedData.uuid; - cfdi.fechaTimbrado = new Date(stampedData.fechaTimbrado); - cfdi.selloCFDI = sello; - cfdi.selloSAT = stampedData.selloSAT; - cfdi.certificadoSAT = stampedData.noCertificadoSAT; - cfdi.cadenaOriginalSAT = stampedData.cadenaOriginalSAT; - cfdi.xmlContent = stampResult.xml; - cfdi.qrData = this.xmlService.generateQRData(cfdi); - cfdi.pacResponse = { - uuid: stampedData.uuid, - fechaTimbrado: stampedData.fechaTimbrado, - rfcProvCertif: stampedData.rfcProvCertif, - acuse: stampResult.acuseRecepcion, - }; - - await queryRunner.manager.save(cfdi); - await queryRunner.commitTransaction(); - - // Send email if configured - if (config.sendEmailOnIssue && cfdi.receptorEmail) { - // Queue email sending (fire and forget) - this.sendCFDIEmail(cfdi, config).catch(err => { - console.error('Failed to send CFDI email:', err); - }); - } - - return { success: true, data: cfdi }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to stamp CFDI', - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Cancel a stamped CFDI - */ - async cancelCFDI( - tenantId: string, - cfdiId: string, - reason: keyof typeof CANCELLATION_REASONS, - substituteUuid: string | undefined, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const cfdi = await queryRunner.manager.findOne(CFDI, { - where: { id: cfdiId, tenantId }, - }); - - if (!cfdi) { - throw new Error('CFDI not found'); - } - - if (cfdi.status !== CFDIStatus.STAMPED) { - throw new Error(`Cannot cancel CFDI in ${cfdi.status} status`); - } - - // Reason 01 requires substitute UUID - if (reason === '01' && !substituteUuid) { - throw new Error('Substitute UUID is required for cancellation reason 01'); - } - - const config = await queryRunner.manager.findOne(CFDIConfig, { - where: { id: cfdi.configId }, - }); - - if (!config) { - throw new Error('CFDI config not found'); - } - - // Update status to pending cancellation - cfdi.status = CFDIStatus.CANCELLATION_PENDING; - cfdi.cancellationReason = reason; - cfdi.substituteUuid = substituteUuid; - cfdi.cancelledBy = userId; - await queryRunner.manager.save(cfdi); - - // Request cancellation from PAC - const cancelResult = await this.pacService.cancelCFDI( - config, - cfdi.uuid, - reason, - substituteUuid - ); - - if (!cancelResult.success) { - cfdi.status = CFDIStatus.STAMPED; // Revert - cfdi.errorCode = cancelResult.errorCode; - cfdi.errorMessage = cancelResult.errorMessage; - await queryRunner.manager.save(cfdi); - await queryRunner.commitTransaction(); - - return { - success: false, - data: cfdi, - error: `Cancellation failed: ${cancelResult.errorMessage}`, - }; - } - - // Update with cancellation result - if (cancelResult.status === 'cancelled') { - cfdi.status = CFDIStatus.CANCELLED; - cfdi.cancelledAt = new Date(); - } - cfdi.cancellationStatus = cancelResult.status; - cfdi.pacResponse = { - ...cfdi.pacResponse, - cancellation: { - status: cancelResult.status, - acuse: cancelResult.acuse, - }, - }; - - await queryRunner.manager.save(cfdi); - await queryRunner.commitTransaction(); - - return { success: true, data: cfdi }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to cancel CFDI', - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Verify CFDI status with SAT - */ - async verifyCFDIStatus( - tenantId: string, - cfdiId: string - ): Promise> { - try { - const cfdi = await this.repository.findOne({ - where: { id: cfdiId, tenantId }, - }); - - if (!cfdi) { - throw new Error('CFDI not found'); - } - - if (!cfdi.uuid) { - throw new Error('CFDI has not been stamped'); - } - - const config = await this.configRepository.findOne({ - where: { id: cfdi.configId }, - }); - - if (!config) { - throw new Error('CFDI config not found'); - } - - const result = await this.pacService.verifyCFDIStatus( - config, - cfdi.emisorRfc, - cfdi.receptorRfc, - cfdi.total, - cfdi.uuid - ); - - if (!result.success) { - return { - success: false, - error: result.errorMessage, - }; - } - - return { - success: true, - data: { - status: result.status, - cancellable: result.status === 'valid', - }, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to verify CFDI', - }; - } - } - - /** - * List CFDIs with filters - */ - async listCFDIs( - tenantId: string, - query: CFDIListQuery - ): Promise<{ data: CFDI[]; meta: { total: number; page: number; limit: number } }> { - const page = query.page || 1; - const limit = query.limit || 20; - const skip = (page - 1) * limit; - - const qb = this.repository.createQueryBuilder('cfdi') - .where('cfdi.tenantId = :tenantId', { tenantId }); - - if (query.status) { - qb.andWhere('cfdi.status = :status', { status: query.status }); - } - - if (query.branchId) { - qb.andWhere('cfdi.branchId = :branchId', { branchId: query.branchId }); - } - - if (query.startDate) { - qb.andWhere('cfdi.fechaEmision >= :startDate', { startDate: query.startDate }); - } - - if (query.endDate) { - qb.andWhere('cfdi.fechaEmision <= :endDate', { endDate: query.endDate }); - } - - if (query.receptorRfc) { - qb.andWhere('cfdi.receptorRfc = :receptorRfc', { receptorRfc: query.receptorRfc }); - } - - if (query.search) { - qb.andWhere( - '(cfdi.receptorNombre ILIKE :search OR cfdi.uuid::text ILIKE :search OR cfdi.serie || cfdi.folio::text ILIKE :search)', - { search: `%${query.search}%` } - ); - } - - qb.orderBy('cfdi.fechaEmision', 'DESC') - .skip(skip) - .take(limit); - - const [data, total] = await qb.getManyAndCount(); - - return { - data, - meta: { total, page, limit }, - }; - } - - /** - * Get CFDI by ID with full details - */ - async getCFDIById(tenantId: string, cfdiId: string): Promise> { - const cfdi = await this.repository.findOne({ - where: { id: cfdiId, tenantId }, - }); - - if (!cfdi) { - return { success: false, error: 'CFDI not found' }; - } - - return { success: true, data: cfdi }; - } - - /** - * Get CFDI by UUID - */ - async getCFDIByUUID(tenantId: string, uuid: string): Promise> { - const cfdi = await this.repository.findOne({ - where: { uuid, tenantId }, - }); - - if (!cfdi) { - return { success: false, error: 'CFDI not found' }; - } - - return { success: true, data: cfdi }; - } - - /** - * Download CFDI XML - */ - async downloadXML(tenantId: string, cfdiId: string): Promise> { - const cfdi = await this.repository.findOne({ - where: { id: cfdiId, tenantId }, - }); - - if (!cfdi) { - return { success: false, error: 'CFDI not found' }; - } - - if (!cfdi.xmlContent) { - return { success: false, error: 'CFDI XML not available' }; - } - - const filename = `${cfdi.uuid || cfdi.serie + cfdi.folio}.xml`; - - return { - success: true, - data: { xml: cfdi.xmlContent, filename }, - }; - } - - /** - * Send CFDI via email - */ - async sendCFDIEmail(cfdi: CFDI, config: CFDIConfig): Promise { - if (!cfdi.receptorEmail) { - throw new Error('No recipient email'); - } - - // TODO: Implement email sending - // This would use your email service - - // Update email sent status - await this.repository.update(cfdi.id, { - emailSent: true, - emailSentAt: new Date(), - emailSentTo: cfdi.receptorEmail, - }); - } - - /** - * Resend CFDI email - */ - async resendEmail( - tenantId: string, - cfdiId: string, - email?: string - ): Promise> { - const cfdi = await this.repository.findOne({ - where: { id: cfdiId, tenantId }, - }); - - if (!cfdi) { - return { success: false, error: 'CFDI not found' }; - } - - const config = await this.configRepository.findOne({ - where: { id: cfdi.configId }, - }); - - if (!config) { - return { success: false, error: 'CFDI config not found' }; - } - - const recipientEmail = email || cfdi.receptorEmail; - if (!recipientEmail) { - return { success: false, error: 'No recipient email provided' }; - } - - // Update email if different - if (email && email !== cfdi.receptorEmail) { - cfdi.receptorEmail = email; - } - - await this.sendCFDIEmail(cfdi, config); - - return { success: true }; - } - - /** - * Create credit note for a CFDI - */ - async createCreditNote( - tenantId: string, - originalCfdiId: string, - lines: OrderLineData[], - reason: string, - userId: string - ): Promise> { - const originalCfdi = await this.repository.findOne({ - where: { id: originalCfdiId, tenantId }, - }); - - if (!originalCfdi) { - return { success: false, error: 'Original CFDI not found' }; - } - - if (originalCfdi.status !== CFDIStatus.STAMPED) { - return { success: false, error: 'Original CFDI must be stamped' }; - } - - const configResult = await this.getActiveConfig(tenantId, originalCfdi.branchId); - if (!configResult.success || !configResult.data) { - return { success: false, error: configResult.error }; - } - - const buildResult = this.builderService.buildCreditNote( - configResult.data, - { - rfc: originalCfdi.receptorRfc, - nombre: originalCfdi.receptorNombre, - usoCFDI: originalCfdi.receptorUsoCFDI, - regimenFiscal: originalCfdi.receptorRegimenFiscal, - domicilioFiscal: originalCfdi.receptorDomicilioFiscal, - }, - originalCfdi.uuid, - lines, - reason - ); - - return this.createCFDI(tenantId, { - configId: configResult.data.id, - branchId: originalCfdi.branchId, - receptor: { - rfc: originalCfdi.receptorRfc, - nombre: originalCfdi.receptorNombre, - usoCFDI: originalCfdi.receptorUsoCFDI, - regimenFiscal: originalCfdi.receptorRegimenFiscal, - domicilioFiscal: originalCfdi.receptorDomicilioFiscal, - email: originalCfdi.receptorEmail, - }, - lines, - formaPago: '99', - metodoPago: 'PUE', - notes: reason, - stampImmediately: true, - }, userId); - } - - /** - * Get CFDI statistics for dashboard - */ - async getStats( - tenantId: string, - startDate: Date, - endDate: Date, - branchId?: string - ): Promise<{ - totalIssued: number; - totalAmount: number; - totalCancelled: number; - byStatus: Record; - }> { - const qb = this.repository.createQueryBuilder('cfdi') - .where('cfdi.tenantId = :tenantId', { tenantId }) - .andWhere('cfdi.fechaEmision >= :startDate', { startDate }) - .andWhere('cfdi.fechaEmision <= :endDate', { endDate }); - - if (branchId) { - qb.andWhere('cfdi.branchId = :branchId', { branchId }); - } - - const [issued, amount, cancelled, statusCounts] = await Promise.all([ - // Total issued (stamped) - qb.clone() - .andWhere('cfdi.status = :status', { status: CFDIStatus.STAMPED }) - .getCount(), - - // Total amount - qb.clone() - .andWhere('cfdi.status = :status', { status: CFDIStatus.STAMPED }) - .select('COALESCE(SUM(cfdi.total), 0)', 'total') - .getRawOne(), - - // Total cancelled - qb.clone() - .andWhere('cfdi.status = :status', { status: CFDIStatus.CANCELLED }) - .getCount(), - - // By status - qb.clone() - .select('cfdi.status', 'status') - .addSelect('COUNT(*)', 'count') - .groupBy('cfdi.status') - .getRawMany(), - ]); - - const byStatus = {} as Record; - for (const status of Object.values(CFDIStatus)) { - byStatus[status] = 0; - } - for (const row of statusCounts) { - byStatus[row.status as CFDIStatus] = parseInt(row.count); - } - - return { - totalIssued: issued, - totalAmount: parseFloat(amount?.total || '0'), - totalCancelled: cancelled, - byStatus, - }; - } -} diff --git a/backend/src/modules/invoicing/services/index.ts b/backend/src/modules/invoicing/services/index.ts deleted file mode 100644 index aa74663..0000000 --- a/backend/src/modules/invoicing/services/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './cfdi.service'; -export * from './cfdi-builder.service'; -export * from './xml.service'; -export * from './pac.service'; diff --git a/backend/src/modules/invoicing/services/pac.service.ts b/backend/src/modules/invoicing/services/pac.service.ts deleted file mode 100644 index fcbbede..0000000 --- a/backend/src/modules/invoicing/services/pac.service.ts +++ /dev/null @@ -1,755 +0,0 @@ -import crypto from 'crypto'; -import { CFDIConfig, PACProvider } from '../entities/cfdi-config.entity'; - -export interface StampResult { - success: boolean; - uuid?: string; - fechaTimbrado?: string; - xml?: string; - acuseRecepcion?: string; - errorCode?: string; - errorMessage?: string; -} - -export interface CancelResult { - success: boolean; - acuse?: string; - status?: string; - errorCode?: string; - errorMessage?: string; -} - -export interface VerifyStatusResult { - success: boolean; - status: 'valid' | 'cancelled' | 'not_found' | 'error'; - cancellationStatus?: string; - cancellationDate?: string; - errorCode?: string; - errorMessage?: string; -} - -interface PACEndpoints { - stamp: string; - cancel: string; - status: string; -} - -const PAC_ENDPOINTS: Record = { - [PACProvider.FINKOK]: { - sandbox: { - stamp: 'https://demo-facturacion.finkok.com/servicios/soap/stamp.wsdl', - cancel: 'https://demo-facturacion.finkok.com/servicios/soap/cancel.wsdl', - status: 'https://demo-facturacion.finkok.com/servicios/soap/utilities.wsdl', - }, - production: { - stamp: 'https://facturacion.finkok.com/servicios/soap/stamp.wsdl', - cancel: 'https://facturacion.finkok.com/servicios/soap/cancel.wsdl', - status: 'https://facturacion.finkok.com/servicios/soap/utilities.wsdl', - }, - }, - [PACProvider.FACTURAPI]: { - sandbox: { - stamp: 'https://www.facturapi.io/v2/invoices', - cancel: 'https://www.facturapi.io/v2/invoices', - status: 'https://www.facturapi.io/v2/invoices', - }, - production: { - stamp: 'https://www.facturapi.io/v2/invoices', - cancel: 'https://www.facturapi.io/v2/invoices', - status: 'https://www.facturapi.io/v2/invoices', - }, - }, - [PACProvider.SW_SAPIEN]: { - sandbox: { - stamp: 'https://services.test.sw.com.mx/cfdi33/stamp/v4', - cancel: 'https://services.test.sw.com.mx/cfdi33/cancel', - status: 'https://services.test.sw.com.mx/cfdi/status', - }, - production: { - stamp: 'https://services.sw.com.mx/cfdi33/stamp/v4', - cancel: 'https://services.sw.com.mx/cfdi33/cancel', - status: 'https://services.sw.com.mx/cfdi/status', - }, - }, - [PACProvider.SOLUCIONES_FISCALES]: { - sandbox: { - stamp: 'https://pruebas.solucionesfiscales.com.mx/ws/timbrar', - cancel: 'https://pruebas.solucionesfiscales.com.mx/ws/cancelar', - status: 'https://pruebas.solucionesfiscales.com.mx/ws/estatus', - }, - production: { - stamp: 'https://www.solucionesfiscales.com.mx/ws/timbrar', - cancel: 'https://www.solucionesfiscales.com.mx/ws/cancelar', - status: 'https://www.solucionesfiscales.com.mx/ws/estatus', - }, - }, - [PACProvider.DIGISAT]: { - sandbox: { - stamp: 'https://sandbox.digisat.com/timbrado', - cancel: 'https://sandbox.digisat.com/cancelacion', - status: 'https://sandbox.digisat.com/estatus', - }, - production: { - stamp: 'https://api.digisat.com/timbrado', - cancel: 'https://api.digisat.com/cancelacion', - status: 'https://api.digisat.com/estatus', - }, - }, - [PACProvider.OTHER]: { - sandbox: { - stamp: '', - cancel: '', - status: '', - }, - production: { - stamp: '', - cancel: '', - status: '', - }, - }, -}; - -// SAT cancellation reason codes -export const CANCELLATION_REASONS = { - '01': 'Comprobante emitido con errores con relación', - '02': 'Comprobante emitido con errores sin relación', - '03': 'No se llevó a cabo la operación', - '04': 'Operación nominativa relacionada en una factura global', -} as const; - -export class PACService { - private encryptionKey: Buffer; - - constructor() { - // Use environment variable for encryption key - const key = process.env.PAC_ENCRYPTION_KEY || 'default-key-change-in-production'; - this.encryptionKey = crypto.scryptSync(key, 'salt', 32); - } - - /** - * Stamp (timbrar) a CFDI with the PAC - */ - async stampCFDI(config: CFDIConfig, signedXml: string): Promise { - const provider = config.pacProvider; - const environment = config.pacEnvironment; - - try { - switch (provider) { - case PACProvider.FINKOK: - return await this.stampWithFinkok(config, signedXml); - case PACProvider.FACTURAPI: - return await this.stampWithFacturapi(config, signedXml); - case PACProvider.SW_SAPIEN: - return await this.stampWithSWSapien(config, signedXml); - default: - return await this.stampGeneric(config, signedXml); - } - } catch (error) { - return { - success: false, - errorCode: 'PAC_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown PAC error', - }; - } - } - - /** - * Cancel a stamped CFDI - */ - async cancelCFDI( - config: CFDIConfig, - uuid: string, - reason: keyof typeof CANCELLATION_REASONS, - substituteUuid?: string - ): Promise { - const provider = config.pacProvider; - - try { - switch (provider) { - case PACProvider.FINKOK: - return await this.cancelWithFinkok(config, uuid, reason, substituteUuid); - case PACProvider.FACTURAPI: - return await this.cancelWithFacturapi(config, uuid, reason, substituteUuid); - case PACProvider.SW_SAPIEN: - return await this.cancelWithSWSapien(config, uuid, reason, substituteUuid); - default: - return await this.cancelGeneric(config, uuid, reason, substituteUuid); - } - } catch (error) { - return { - success: false, - errorCode: 'PAC_CANCEL_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown cancellation error', - }; - } - } - - /** - * Verify CFDI status with SAT - */ - async verifyCFDIStatus( - config: CFDIConfig, - emisorRfc: string, - receptorRfc: string, - total: number, - uuid: string - ): Promise { - const provider = config.pacProvider; - - try { - switch (provider) { - case PACProvider.FINKOK: - return await this.verifyWithFinkok(config, emisorRfc, receptorRfc, total, uuid); - default: - return await this.verifyGeneric(config, emisorRfc, receptorRfc, total, uuid); - } - } catch (error) { - return { - success: false, - status: 'error', - errorCode: 'PAC_VERIFY_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown verification error', - }; - } - } - - // ==================== FINKOK Implementation ==================== - - private async stampWithFinkok(config: CFDIConfig, signedXml: string): Promise { - const credentials = this.decryptCredentials(config); - const endpoint = this.getEndpoint(config, 'stamp'); - - // Build SOAP request for Finkok stamp service - const soapRequest = this.buildFinkokStampRequest( - credentials.username, - credentials.password, - signedXml - ); - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - 'SOAPAction': 'stamp', - }, - body: soapRequest, - }); - - if (!response.ok) { - throw new Error(`Finkok API error: ${response.status} ${response.statusText}`); - } - - const responseText = await response.text(); - return this.parseFinkokStampResponse(responseText); - } - - private buildFinkokStampRequest(username: string, password: string, xml: string): string { - const xmlBase64 = Buffer.from(xml).toString('base64'); - - return ` - - - - - ${xmlBase64} - ${username} - ${password} - - -`; - } - - private parseFinkokStampResponse(responseXml: string): StampResult { - // Check for Incidencias (errors) - const incidenciaMatch = responseXml.match(/([^<]+)<\/CodigoError>[\s\S]*?([^<]+)<\/MensajeIncidencia>/); - if (incidenciaMatch) { - return { - success: false, - errorCode: incidenciaMatch[1], - errorMessage: incidenciaMatch[2], - }; - } - - // Extract stamped XML - const xmlMatch = responseXml.match(/([^<]+)<\/xml>/); - const uuidMatch = responseXml.match(/([^<]+)<\/UUID>/); - const fechaMatch = responseXml.match(/([^<]+)<\/Fecha>/); - const acuseMatch = responseXml.match(/([^<]+)<\/Acuse>/); - - if (!xmlMatch || !uuidMatch) { - return { - success: false, - errorCode: 'PARSE_ERROR', - errorMessage: 'Could not parse Finkok response', - }; - } - - const stampedXml = Buffer.from(xmlMatch[1], 'base64').toString('utf-8'); - - return { - success: true, - uuid: uuidMatch[1], - fechaTimbrado: fechaMatch?.[1], - xml: stampedXml, - acuseRecepcion: acuseMatch?.[1], - }; - } - - private async cancelWithFinkok( - config: CFDIConfig, - uuid: string, - reason: string, - substituteUuid?: string - ): Promise { - const credentials = this.decryptCredentials(config); - const endpoint = this.getEndpoint(config, 'cancel'); - - const soapRequest = this.buildFinkokCancelRequest( - credentials.username, - credentials.password, - config.rfc, - uuid, - reason, - substituteUuid - ); - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - 'SOAPAction': 'cancel', - }, - body: soapRequest, - }); - - if (!response.ok) { - throw new Error(`Finkok cancel error: ${response.status}`); - } - - const responseText = await response.text(); - return this.parseFinkokCancelResponse(responseText); - } - - private buildFinkokCancelRequest( - username: string, - password: string, - rfc: string, - uuid: string, - reason: string, - substituteUuid?: string - ): string { - const folioSustitucion = substituteUuid || ''; - - return ` - - - - - - ${uuid} - - ${username} - ${password} - ${rfc} - ${reason} - ${folioSustitucion} - - -`; - } - - private parseFinkokCancelResponse(responseXml: string): CancelResult { - const statusMatch = responseXml.match(/([^<]+)<\/EstatusUUID>/); - const acuseMatch = responseXml.match(/([^<]+)<\/Acuse>/); - const errorMatch = responseXml.match(/([^<]+)<\/CodEstatus>/); - - if (statusMatch?.[1] === '201' || statusMatch?.[1] === '202') { - return { - success: true, - status: statusMatch[1] === '201' ? 'cancelled' : 'pending', - acuse: acuseMatch?.[1], - }; - } - - return { - success: false, - status: statusMatch?.[1], - errorCode: errorMatch?.[1] || 'UNKNOWN', - errorMessage: 'Cancellation failed', - }; - } - - private async verifyWithFinkok( - config: CFDIConfig, - emisorRfc: string, - receptorRfc: string, - total: number, - uuid: string - ): Promise { - const credentials = this.decryptCredentials(config); - const endpoint = this.getEndpoint(config, 'status'); - - const soapRequest = ` - - - - - ${credentials.username} - ${credentials.password} - ${emisorRfc} - ${receptorRfc} - ${uuid} - ${total.toFixed(2)} - - -`; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - 'SOAPAction': 'get_sat_status', - }, - body: soapRequest, - }); - - if (!response.ok) { - throw new Error(`Finkok status error: ${response.status}`); - } - - const responseText = await response.text(); - return this.parseFinkokStatusResponse(responseText); - } - - private parseFinkokStatusResponse(responseXml: string): VerifyStatusResult { - const estadoMatch = responseXml.match(/([^<]+)<\/Estado>/); - const cancelableMatch = responseXml.match(/([^<]+)<\/EsCancelable>/); - const estatusCancelacionMatch = responseXml.match(/([^<]+)<\/EstatusCancelacion>/); - - if (!estadoMatch) { - return { - success: false, - status: 'error', - errorCode: 'PARSE_ERROR', - errorMessage: 'Could not parse status response', - }; - } - - let status: VerifyStatusResult['status'] = 'valid'; - if (estadoMatch[1] === 'Cancelado') { - status = 'cancelled'; - } else if (estadoMatch[1] === 'No Encontrado') { - status = 'not_found'; - } - - return { - success: true, - status, - cancellationStatus: estatusCancelacionMatch?.[1], - }; - } - - // ==================== Facturapi Implementation ==================== - - private async stampWithFacturapi(config: CFDIConfig, signedXml: string): Promise { - const credentials = this.decryptCredentials(config); - const endpoint = this.getEndpoint(config, 'stamp'); - - const response = await fetch(`${endpoint}/stamp`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${credentials.password}`, - 'Content-Type': 'application/xml', - }, - body: signedXml, - }); - - if (!response.ok) { - const error = await response.json(); - return { - success: false, - errorCode: error.code, - errorMessage: error.message, - }; - } - - const result = await response.json(); - return { - success: true, - uuid: result.uuid, - fechaTimbrado: result.stamp_date, - xml: result.xml, - }; - } - - private async cancelWithFacturapi( - config: CFDIConfig, - uuid: string, - reason: string, - substituteUuid?: string - ): Promise { - const credentials = this.decryptCredentials(config); - const endpoint = this.getEndpoint(config, 'cancel'); - - const response = await fetch(`${endpoint}/${uuid}`, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${credentials.password}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - motive: reason, - substitute: substituteUuid, - }), - }); - - if (!response.ok) { - const error = await response.json(); - return { - success: false, - errorCode: error.code, - errorMessage: error.message, - }; - } - - const result = await response.json(); - return { - success: true, - status: result.status, - acuse: result.acuse, - }; - } - - // ==================== SW Sapien Implementation ==================== - - private async stampWithSWSapien(config: CFDIConfig, signedXml: string): Promise { - const credentials = this.decryptCredentials(config); - const endpoint = this.getEndpoint(config, 'stamp'); - - // First get auth token - const authResponse = await fetch('https://services.sw.com.mx/security/authenticate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - user: credentials.username, - password: credentials.password, - }), - }); - - if (!authResponse.ok) { - throw new Error('SW Sapien authentication failed'); - } - - const authResult = await authResponse.json(); - const token = authResult.data.token; - - // Stamp with token - const xmlBase64 = Buffer.from(signedXml).toString('base64'); - const stampResponse = await fetch(endpoint, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ xml: xmlBase64 }), - }); - - if (!stampResponse.ok) { - const error = await stampResponse.json(); - return { - success: false, - errorCode: error.messageDetail, - errorMessage: error.message, - }; - } - - const result = await stampResponse.json(); - return { - success: true, - uuid: result.data.uuid, - fechaTimbrado: result.data.fechaTimbrado, - xml: Buffer.from(result.data.cfdi, 'base64').toString('utf-8'), - }; - } - - private async cancelWithSWSapien( - config: CFDIConfig, - uuid: string, - reason: string, - substituteUuid?: string - ): Promise { - // Similar implementation with SW Sapien API - return { - success: false, - errorCode: 'NOT_IMPLEMENTED', - errorMessage: 'SW Sapien cancellation not yet implemented', - }; - } - - // ==================== Generic Implementations ==================== - - private async stampGeneric(config: CFDIConfig, signedXml: string): Promise { - if (!config.pacApiUrl) { - return { - success: false, - errorCode: 'NO_API_URL', - errorMessage: 'No PAC API URL configured', - }; - } - - const credentials = this.decryptCredentials(config); - - const response = await fetch(config.pacApiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/xml', - 'Authorization': `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`, - }, - body: signedXml, - }); - - if (!response.ok) { - return { - success: false, - errorCode: 'API_ERROR', - errorMessage: `API returned ${response.status}`, - }; - } - - const xml = await response.text(); - return { - success: true, - xml, - }; - } - - private async cancelGeneric( - config: CFDIConfig, - uuid: string, - reason: string, - substituteUuid?: string - ): Promise { - return { - success: false, - errorCode: 'NOT_IMPLEMENTED', - errorMessage: 'Generic cancellation not implemented. Configure a specific PAC provider.', - }; - } - - private async verifyGeneric( - config: CFDIConfig, - emisorRfc: string, - receptorRfc: string, - total: number, - uuid: string - ): Promise { - return { - success: false, - status: 'error', - errorCode: 'NOT_IMPLEMENTED', - errorMessage: 'Generic verification not implemented. Configure a specific PAC provider.', - }; - } - - // ==================== Helpers ==================== - - private getEndpoint(config: CFDIConfig, type: keyof PACEndpoints): string { - const provider = config.pacProvider; - const environment = config.pacEnvironment; - const endpoints = PAC_ENDPOINTS[provider]; - - if (!endpoints) { - throw new Error(`Unknown PAC provider: ${provider}`); - } - - return endpoints[environment][type]; - } - - private decryptCredentials(config: CFDIConfig): { username: string; password: string } { - return { - username: config.pacUsername || '', - password: config.pacPasswordEncrypted - ? this.decrypt(config.pacPasswordEncrypted) - : '', - }; - } - - /** - * Encrypt sensitive data - */ - encrypt(text: string): string { - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv('aes-256-cbc', this.encryptionKey, iv); - let encrypted = cipher.update(text, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - return iv.toString('hex') + ':' + encrypted; - } - - /** - * Decrypt sensitive data - */ - decrypt(encryptedText: string): string { - const [ivHex, encrypted] = encryptedText.split(':'); - const iv = Buffer.from(ivHex, 'hex'); - const decipher = crypto.createDecipheriv('aes-256-cbc', this.encryptionKey, iv); - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; - } - - /** - * Decrypt and load certificate - */ - async loadCertificate(config: CFDIConfig): Promise<{ - certificate: Buffer; - privateKey: Buffer; - password: string; - }> { - if (!config.certificateEncrypted || !config.privateKeyEncrypted) { - throw new Error('Certificate not configured'); - } - - return { - certificate: Buffer.from(this.decrypt(config.certificateEncrypted), 'base64'), - privateKey: Buffer.from(this.decrypt(config.privateKeyEncrypted), 'base64'), - password: config.certificatePasswordEncrypted - ? this.decrypt(config.certificatePasswordEncrypted) - : '', - }; - } - - /** - * Sign data with private key (for CFDI sello) - */ - async signData(data: string, privateKey: Buffer, password: string): Promise { - const sign = crypto.createSign('RSA-SHA256'); - sign.update(data, 'utf8'); - const signature = sign.sign({ - key: privateKey, - passphrase: password, - }); - return signature.toString('base64'); - } - - /** - * Get certificate as base64 - */ - getCertificateBase64(certificate: Buffer): string { - // Convert DER to PEM format if needed, then extract base64 - const pemContent = certificate.toString('utf8'); - if (pemContent.includes('-----BEGIN CERTIFICATE-----')) { - // Already PEM, extract base64 content - return pemContent - .replace('-----BEGIN CERTIFICATE-----', '') - .replace('-----END CERTIFICATE-----', '') - .replace(/\s+/g, ''); - } - // Already raw/DER format - return certificate.toString('base64'); - } -} diff --git a/backend/src/modules/invoicing/services/xml.service.ts b/backend/src/modules/invoicing/services/xml.service.ts deleted file mode 100644 index 2321136..0000000 --- a/backend/src/modules/invoicing/services/xml.service.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { create, fragment } from 'xmlbuilder2'; -import { CFDI, CFDIType } from '../entities/cfdi.entity'; -import { CFDIConfig } from '../entities/cfdi-config.entity'; - -export interface CFDIConcepto { - claveProdServ: string; - claveUnidad: string; - cantidad: number; - unidad: string; - descripcion: string; - valorUnitario: number; - importe: number; - descuento?: number; - objetoImp: string; - impuestos?: { - traslados?: { - base: number; - impuesto: string; - tipoFactor: string; - tasaOCuota: number; - importe: number; - }[]; - retenciones?: { - base: number; - impuesto: string; - tipoFactor: string; - tasaOCuota: number; - importe: number; - }[]; - }; -} - -export interface CFDIImpuestos { - traslados?: { - impuesto: string; - tipoFactor: string; - tasaOCuota: number; - importe: number; - base: number; - }[]; - retenciones?: { - impuesto: string; - importe: number; - }[]; -} - -export interface CFDIData { - version: string; - serie: string; - folio: number; - fecha: string; - formaPago: string; - metodoPago: string; - tipoDeComprobante: string; - exportacion: string; - lugarExpedicion: string; - moneda: string; - tipoCambio?: number; - subTotal: number; - descuento?: number; - total: number; - condicionesDePago?: string; - emisor: { - rfc: string; - nombre: string; - regimenFiscal: string; - }; - receptor: { - rfc: string; - nombre: string; - usoCFDI: string; - domicilioFiscalReceptor?: string; - regimenFiscalReceptor?: string; - }; - conceptos: CFDIConcepto[]; - impuestos?: CFDIImpuestos; - cfdiRelacionados?: { - tipoRelacion: string; - uuids: string[]; - }; -} - -export interface TimbreData { - version: string; - uuid: string; - fechaTimbrado: string; - rfcProvCertif: string; - selloCFD: string; - noCertificadoSAT: string; - selloSAT: string; -} - -const CFDI_NAMESPACE = 'http://www.sat.gob.mx/cfd/4'; -const TIMBRE_NAMESPACE = 'http://www.sat.gob.mx/TimbreFiscalDigital'; -const XSI_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance'; - -export class XMLService { - /** - * Build CFDI XML without stamps (for signing) - */ - buildCFDIXML(data: CFDIData, certificateNumber: string): string { - const schemaLocation = `${CFDI_NAMESPACE} http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd`; - - const doc = create({ version: '1.0', encoding: 'UTF-8' }) - .ele(CFDI_NAMESPACE, 'cfdi:Comprobante') - .att('xmlns:cfdi', CFDI_NAMESPACE) - .att('xmlns:xsi', XSI_NAMESPACE) - .att('xsi:schemaLocation', schemaLocation) - .att('Version', data.version) - .att('Serie', data.serie) - .att('Folio', data.folio.toString()) - .att('Fecha', data.fecha) - .att('FormaPago', data.formaPago) - .att('SubTotal', this.formatMoney(data.subTotal)) - .att('Moneda', data.moneda) - .att('Total', this.formatMoney(data.total)) - .att('TipoDeComprobante', data.tipoDeComprobante) - .att('Exportacion', data.exportacion) - .att('MetodoPago', data.metodoPago) - .att('LugarExpedicion', data.lugarExpedicion) - .att('NoCertificado', certificateNumber); - - if (data.tipoCambio && data.tipoCambio !== 1) { - doc.att('TipoCambio', data.tipoCambio.toString()); - } - - if (data.descuento && data.descuento > 0) { - doc.att('Descuento', this.formatMoney(data.descuento)); - } - - if (data.condicionesDePago) { - doc.att('CondicionesDePago', data.condicionesDePago); - } - - // CFDI Relacionados - if (data.cfdiRelacionados && data.cfdiRelacionados.uuids.length > 0) { - const relacionados = doc.ele('cfdi:CfdiRelacionados') - .att('TipoRelacion', data.cfdiRelacionados.tipoRelacion); - - for (const uuid of data.cfdiRelacionados.uuids) { - relacionados.ele('cfdi:CfdiRelacionado').att('UUID', uuid); - } - } - - // Emisor - doc.ele('cfdi:Emisor') - .att('Rfc', data.emisor.rfc) - .att('Nombre', data.emisor.nombre) - .att('RegimenFiscal', data.emisor.regimenFiscal); - - // Receptor - const receptor = doc.ele('cfdi:Receptor') - .att('Rfc', data.receptor.rfc) - .att('Nombre', data.receptor.nombre) - .att('UsoCFDI', data.receptor.usoCFDI); - - if (data.receptor.domicilioFiscalReceptor) { - receptor.att('DomicilioFiscalReceptor', data.receptor.domicilioFiscalReceptor); - } - if (data.receptor.regimenFiscalReceptor) { - receptor.att('RegimenFiscalReceptor', data.receptor.regimenFiscalReceptor); - } - - // Conceptos - const conceptos = doc.ele('cfdi:Conceptos'); - for (const concepto of data.conceptos) { - const conceptoNode = conceptos.ele('cfdi:Concepto') - .att('ClaveProdServ', concepto.claveProdServ) - .att('Cantidad', concepto.cantidad.toString()) - .att('ClaveUnidad', concepto.claveUnidad) - .att('Unidad', concepto.unidad) - .att('Descripcion', concepto.descripcion) - .att('ValorUnitario', this.formatMoney(concepto.valorUnitario)) - .att('Importe', this.formatMoney(concepto.importe)) - .att('ObjetoImp', concepto.objetoImp); - - if (concepto.descuento && concepto.descuento > 0) { - conceptoNode.att('Descuento', this.formatMoney(concepto.descuento)); - } - - // Impuestos del concepto - if (concepto.impuestos) { - const impuestosNode = conceptoNode.ele('cfdi:Impuestos'); - - if (concepto.impuestos.traslados && concepto.impuestos.traslados.length > 0) { - const trasladosNode = impuestosNode.ele('cfdi:Traslados'); - for (const traslado of concepto.impuestos.traslados) { - trasladosNode.ele('cfdi:Traslado') - .att('Base', this.formatMoney(traslado.base)) - .att('Impuesto', traslado.impuesto) - .att('TipoFactor', traslado.tipoFactor) - .att('TasaOCuota', this.formatRate(traslado.tasaOCuota)) - .att('Importe', this.formatMoney(traslado.importe)); - } - } - - if (concepto.impuestos.retenciones && concepto.impuestos.retenciones.length > 0) { - const retencionesNode = impuestosNode.ele('cfdi:Retenciones'); - for (const retencion of concepto.impuestos.retenciones) { - retencionesNode.ele('cfdi:Retencion') - .att('Base', this.formatMoney(retencion.base)) - .att('Impuesto', retencion.impuesto) - .att('TipoFactor', retencion.tipoFactor) - .att('TasaOCuota', this.formatRate(retencion.tasaOCuota)) - .att('Importe', this.formatMoney(retencion.importe)); - } - } - } - } - - // Impuestos generales - if (data.impuestos) { - const impuestosNode = doc.ele('cfdi:Impuestos'); - - if (data.impuestos.retenciones && data.impuestos.retenciones.length > 0) { - const totalRetenido = data.impuestos.retenciones.reduce((sum, r) => sum + r.importe, 0); - impuestosNode.att('TotalImpuestosRetenidos', this.formatMoney(totalRetenido)); - - const retencionesNode = impuestosNode.ele('cfdi:Retenciones'); - for (const retencion of data.impuestos.retenciones) { - retencionesNode.ele('cfdi:Retencion') - .att('Impuesto', retencion.impuesto) - .att('Importe', this.formatMoney(retencion.importe)); - } - } - - if (data.impuestos.traslados && data.impuestos.traslados.length > 0) { - const totalTrasladado = data.impuestos.traslados.reduce((sum, t) => sum + t.importe, 0); - impuestosNode.att('TotalImpuestosTrasladados', this.formatMoney(totalTrasladado)); - - const trasladosNode = impuestosNode.ele('cfdi:Traslados'); - for (const traslado of data.impuestos.traslados) { - trasladosNode.ele('cfdi:Traslado') - .att('Base', this.formatMoney(traslado.base)) - .att('Impuesto', traslado.impuesto) - .att('TipoFactor', traslado.tipoFactor) - .att('TasaOCuota', this.formatRate(traslado.tasaOCuota)) - .att('Importe', this.formatMoney(traslado.importe)); - } - } - } - - return doc.end({ prettyPrint: false }); - } - - /** - * Add seal (sello) to XML - */ - addSealToXML(xml: string, sello: string): string { - // Insert Sello attribute after NoCertificado - return xml.replace( - /NoCertificado="([^"]+)"/, - `NoCertificado="$1" Sello="${sello}"` - ); - } - - /** - * Add certificate to XML - */ - addCertificateToXML(xml: string, certificateBase64: string): string { - // Insert Certificado attribute after Sello - return xml.replace( - /Sello="([^"]+)"/, - `Sello="$1" Certificado="${certificateBase64}"` - ); - } - - /** - * Add TimbreFiscalDigital complement to stamped XML - */ - addTimbreToXML(xml: string, timbre: TimbreData): string { - const timbreFragment = fragment() - .ele(TIMBRE_NAMESPACE, 'tfd:TimbreFiscalDigital') - .att('xmlns:tfd', TIMBRE_NAMESPACE) - .att('xmlns:xsi', XSI_NAMESPACE) - .att('xsi:schemaLocation', `${TIMBRE_NAMESPACE} http://www.sat.gob.mx/sitio_internet/cfd/TimbreFiscalDigital/TimbreFiscalDigitalv11.xsd`) - .att('Version', timbre.version) - .att('UUID', timbre.uuid) - .att('FechaTimbrado', timbre.fechaTimbrado) - .att('RfcProvCertif', timbre.rfcProvCertif) - .att('SelloCFD', timbre.selloCFD) - .att('NoCertificadoSAT', timbre.noCertificadoSAT) - .att('SelloSAT', timbre.selloSAT) - .end(); - - // Find closing tag and insert Complemento before it - const insertPoint = xml.indexOf(''); - if (insertPoint === -1) { - throw new Error('Invalid CFDI XML: missing closing tag'); - } - - return xml.slice(0, insertPoint) + - '' + timbreFragment + '' + - xml.slice(insertPoint); - } - - /** - * Build original chain for signing (cadena original) - */ - buildCadenaOriginal(data: CFDIData, certificateNumber: string): string { - const parts: string[] = []; - - // Version and main attributes - parts.push(data.version); - parts.push(data.serie); - parts.push(data.folio.toString()); - parts.push(data.fecha); - parts.push(data.formaPago); - parts.push(certificateNumber); - if (data.condicionesDePago) parts.push(data.condicionesDePago); - parts.push(this.formatMoney(data.subTotal)); - if (data.descuento && data.descuento > 0) { - parts.push(this.formatMoney(data.descuento)); - } - parts.push(data.moneda); - if (data.tipoCambio && data.tipoCambio !== 1) { - parts.push(data.tipoCambio.toString()); - } - parts.push(this.formatMoney(data.total)); - parts.push(data.tipoDeComprobante); - parts.push(data.exportacion); - parts.push(data.metodoPago); - parts.push(data.lugarExpedicion); - - // CFDI Relacionados - if (data.cfdiRelacionados && data.cfdiRelacionados.uuids.length > 0) { - parts.push(data.cfdiRelacionados.tipoRelacion); - for (const uuid of data.cfdiRelacionados.uuids) { - parts.push(uuid); - } - } - - // Emisor - parts.push(data.emisor.rfc); - parts.push(data.emisor.nombre); - parts.push(data.emisor.regimenFiscal); - - // Receptor - parts.push(data.receptor.rfc); - parts.push(data.receptor.nombre); - if (data.receptor.domicilioFiscalReceptor) { - parts.push(data.receptor.domicilioFiscalReceptor); - } - if (data.receptor.regimenFiscalReceptor) { - parts.push(data.receptor.regimenFiscalReceptor); - } - parts.push(data.receptor.usoCFDI); - - // Conceptos - for (const concepto of data.conceptos) { - parts.push(concepto.claveProdServ); - parts.push(concepto.cantidad.toString()); - parts.push(concepto.claveUnidad); - parts.push(concepto.unidad); - parts.push(concepto.descripcion); - parts.push(this.formatMoney(concepto.valorUnitario)); - parts.push(this.formatMoney(concepto.importe)); - if (concepto.descuento && concepto.descuento > 0) { - parts.push(this.formatMoney(concepto.descuento)); - } - parts.push(concepto.objetoImp); - - // Impuestos del concepto - if (concepto.impuestos) { - if (concepto.impuestos.traslados) { - for (const traslado of concepto.impuestos.traslados) { - parts.push(this.formatMoney(traslado.base)); - parts.push(traslado.impuesto); - parts.push(traslado.tipoFactor); - parts.push(this.formatRate(traslado.tasaOCuota)); - parts.push(this.formatMoney(traslado.importe)); - } - } - if (concepto.impuestos.retenciones) { - for (const retencion of concepto.impuestos.retenciones) { - parts.push(this.formatMoney(retencion.base)); - parts.push(retencion.impuesto); - parts.push(retencion.tipoFactor); - parts.push(this.formatRate(retencion.tasaOCuota)); - parts.push(this.formatMoney(retencion.importe)); - } - } - } - } - - // Impuestos generales - if (data.impuestos) { - if (data.impuestos.retenciones) { - const totalRetenido = data.impuestos.retenciones.reduce((sum, r) => sum + r.importe, 0); - parts.push(this.formatMoney(totalRetenido)); - for (const retencion of data.impuestos.retenciones) { - parts.push(retencion.impuesto); - parts.push(this.formatMoney(retencion.importe)); - } - } - if (data.impuestos.traslados) { - const totalTrasladado = data.impuestos.traslados.reduce((sum, t) => sum + t.importe, 0); - parts.push(this.formatMoney(totalTrasladado)); - for (const traslado of data.impuestos.traslados) { - parts.push(this.formatMoney(traslado.base)); - parts.push(traslado.impuesto); - parts.push(traslado.tipoFactor); - parts.push(this.formatRate(traslado.tasaOCuota)); - parts.push(this.formatMoney(traslado.importe)); - } - } - } - - return '||' + parts.join('|') + '||'; - } - - /** - * Parse stamped XML response from PAC - */ - parseStampedXML(xml: string): { - uuid: string; - fechaTimbrado: string; - selloCFD: string; - noCertificadoSAT: string; - selloSAT: string; - rfcProvCertif: string; - cadenaOriginalSAT: string; - } { - // Extract TimbreFiscalDigital attributes using regex (faster than full XML parse) - const uuidMatch = xml.match(/UUID="([^"]+)"/); - const fechaMatch = xml.match(/FechaTimbrado="([^"]+)"/); - const selloCFDMatch = xml.match(/SelloCFD="([^"]+)"/); - const noCertMatch = xml.match(/NoCertificadoSAT="([^"]+)"/); - const selloSATMatch = xml.match(/SelloSAT="([^"]+)"/); - const rfcProvMatch = xml.match(/RfcProvCertif="([^"]+)"/); - - if (!uuidMatch || !fechaMatch || !selloSATMatch || !noCertMatch) { - throw new Error('Invalid stamped XML: missing TimbreFiscalDigital attributes'); - } - - // Build SAT cadena original from timbre - const cadenaOriginalSAT = this.buildCadenaOriginalSAT({ - version: '1.1', - uuid: uuidMatch[1], - fechaTimbrado: fechaMatch[1], - rfcProvCertif: rfcProvMatch?.[1] || '', - selloCFD: selloCFDMatch?.[1] || '', - noCertificadoSAT: noCertMatch[1], - selloSAT: selloSATMatch[1], - }); - - return { - uuid: uuidMatch[1], - fechaTimbrado: fechaMatch[1], - selloCFD: selloCFDMatch?.[1] || '', - noCertificadoSAT: noCertMatch[1], - selloSAT: selloSATMatch[1], - rfcProvCertif: rfcProvMatch?.[1] || '', - cadenaOriginalSAT, - }; - } - - /** - * Build SAT cadena original from timbre - */ - private buildCadenaOriginalSAT(timbre: TimbreData): string { - const parts: string[] = []; - parts.push(timbre.version); - parts.push(timbre.uuid); - parts.push(timbre.fechaTimbrado); - parts.push(timbre.rfcProvCertif); - parts.push(timbre.selloCFD); - parts.push(timbre.noCertificadoSAT); - return '||' + parts.join('|') + '||'; - } - - /** - * Generate QR code data string - */ - generateQRData(cfdi: CFDI): string { - // Format: https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?id=uuid&re=rfcEmisor&rr=rfcReceptor&tt=total&fe=ultimos8SelloCFD - const total = cfdi.total.toFixed(2).padStart(17, '0'); - const selloLast8 = cfdi.selloCFDI?.slice(-8) || '00000000'; - - return `https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?id=${cfdi.uuid}&re=${cfdi.emisorRfc}&rr=${cfdi.receptorRfc}&tt=${total}&fe=${selloLast8}`; - } - - /** - * Format money value for CFDI (2 decimal places) - */ - private formatMoney(value: number): string { - return value.toFixed(2); - } - - /** - * Format rate/percentage for CFDI (6 decimal places) - */ - private formatRate(value: number): string { - return value.toFixed(6); - } - - /** - * Format date for CFDI (ISO 8601 without timezone) - */ - formatCFDIDate(date: Date): string { - return date.toISOString().replace('Z', '').slice(0, 19); - } - - /** - * Validate XML structure - */ - validateXMLStructure(xml: string): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - // Check required elements - if (!xml.includes('cfdi:Comprobante')) { - errors.push('Missing Comprobante element'); - } - if (!xml.includes('cfdi:Emisor')) { - errors.push('Missing Emisor element'); - } - if (!xml.includes('cfdi:Receptor')) { - errors.push('Missing Receptor element'); - } - if (!xml.includes('cfdi:Conceptos')) { - errors.push('Missing Conceptos element'); - } - - // Check version - if (!xml.includes('Version="4.0"')) { - errors.push('Invalid or missing CFDI version (expected 4.0)'); - } - - return { - valid: errors.length === 0, - errors, - }; - } -} diff --git a/backend/src/modules/invoicing/validation/cfdi.schema.ts b/backend/src/modules/invoicing/validation/cfdi.schema.ts deleted file mode 100644 index 94f14c6..0000000 --- a/backend/src/modules/invoicing/validation/cfdi.schema.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { z } from 'zod'; -import { - uuidSchema, - moneySchema, - paginationSchema, -} from '../../../shared/validation/common.schema'; - -// RFC validation (12-13 characters) -const rfcSchema = z.string() - .min(12, 'RFC must be at least 12 characters') - .max(13, 'RFC must be at most 13 characters') - .regex(/^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/i, 'Invalid RFC format'); - -// SAT catalog codes -const claveProdServSchema = z.string().length(8, 'ClaveProdServ must be 8 digits').regex(/^\d{8}$/); -const claveUnidadSchema = z.string().min(1).max(4); -const regimenFiscalSchema = z.string().length(3, 'RegimenFiscal must be 3 characters'); -const usoCFDISchema = z.string().length(3).or(z.string().length(4)); -const codigoPostalSchema = z.string().length(5, 'Código postal must be 5 digits').regex(/^\d{5}$/); -const formaPagoSchema = z.string().length(2, 'FormaPago must be 2 characters'); -const metodoPagoSchema = z.enum(['PUE', 'PPD']); -const monedaSchema = z.string().length(3, 'Moneda must be 3 characters').default('MXN'); - -// CFDI Status enum -export const cfdiStatusEnum = z.enum([ - 'draft', - 'pending', - 'stamped', - 'cancelled', - 'cancellation_pending', - 'error', -]); - -// CFDI Type enum -export const cfdiTypeEnum = z.enum(['I', 'E', 'T', 'N', 'P']); - -// Cancellation reason enum -export const cancellationReasonEnum = z.enum(['01', '02', '03', '04']); - -// ==================== RECEPTOR SCHEMAS ==================== - -export const receptorSchema = z.object({ - rfc: rfcSchema, - nombre: z.string().min(1).max(300), - usoCFDI: usoCFDISchema, - regimenFiscal: regimenFiscalSchema.optional(), - domicilioFiscal: codigoPostalSchema.optional(), - email: z.string().email().max(100).optional(), -}); - -// ==================== LINE ITEM SCHEMAS ==================== - -export const orderLineSchema = z.object({ - productId: uuidSchema.optional(), - productCode: z.string().min(1).max(50), - productName: z.string().min(1).max(1000), - claveProdServ: claveProdServSchema, - claveUnidad: claveUnidadSchema, - unidad: z.string().min(1).max(20), - quantity: z.number().positive(), - unitPrice: moneySchema.positive(), - discount: moneySchema.optional(), - taxRate: z.number().min(0).max(1).default(0.16), - taxIncluded: z.boolean().optional(), - retentionIVA: z.number().min(0).max(1).optional(), - retentionISR: z.number().min(0).max(1).optional(), - objetoImp: z.enum(['01', '02', '03']).optional(), -}); - -// ==================== CREATE CFDI SCHEMA ==================== - -export const createCFDISchema = z.object({ - configId: uuidSchema.optional(), - branchId: uuidSchema.optional(), - receptor: receptorSchema, - lines: z.array(orderLineSchema).min(1, 'At least one line item is required'), - formaPago: formaPagoSchema, - metodoPago: metodoPagoSchema, - moneda: monedaSchema.optional(), - tipoCambio: z.number().positive().optional(), - condicionesPago: z.string().max(100).optional(), - orderId: uuidSchema.optional(), - orderNumber: z.string().max(30).optional(), - orderType: z.enum(['pos', 'ecommerce']).optional(), - notes: z.string().max(1000).optional(), - stampImmediately: z.boolean().optional().default(true), -}); - -// ==================== CANCEL CFDI SCHEMA ==================== - -export const cancelCFDISchema = z.object({ - reason: cancellationReasonEnum, - substituteUuid: z.string().uuid().optional(), -}).refine( - (data) => { - // If reason is 01, substituteUuid is required - if (data.reason === '01' && !data.substituteUuid) { - return false; - } - return true; - }, - { - message: 'Substitute UUID is required for cancellation reason 01', - path: ['substituteUuid'], - } -); - -// ==================== LIST CFDI QUERY SCHEMA ==================== - -export const listCFDIsQuerySchema = paginationSchema.extend({ - status: cfdiStatusEnum.optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - receptorRfc: rfcSchema.optional(), - branchId: uuidSchema.optional(), - search: z.string().max(100).optional(), -}); - -// ==================== RESEND EMAIL SCHEMA ==================== - -export const resendEmailSchema = z.object({ - email: z.string().email().max(100).optional(), -}); - -// ==================== CREATE CREDIT NOTE SCHEMA ==================== - -export const createCreditNoteSchema = z.object({ - originalCfdiId: uuidSchema, - lines: z.array(orderLineSchema).min(1), - reason: z.string().min(1).max(500), -}); - -// ==================== CFDI CONFIG SCHEMAS ==================== - -export const pacProviderEnum = z.enum([ - 'finkok', - 'soluciones_fiscales', - 'facturapi', - 'sw_sapien', - 'digisat', - 'other', -]); - -export const cfdiConfigStatusEnum = z.enum([ - 'active', - 'inactive', - 'pending_validation', - 'expired', -]); - -export const createCFDIConfigSchema = z.object({ - branchId: uuidSchema.optional(), - rfc: rfcSchema, - razonSocial: z.string().min(1).max(300), - nombreComercial: z.string().max(200).optional(), - regimenFiscal: regimenFiscalSchema, - regimenFiscalDescripcion: z.string().max(200).optional(), - codigoPostal: codigoPostalSchema, - domicilio: z.string().max(500).optional(), - - // PAC configuration - pacProvider: pacProviderEnum.default('finkok'), - pacUsername: z.string().max(100).optional(), - pacPassword: z.string().max(255).optional(), - pacEnvironment: z.enum(['sandbox', 'production']).default('sandbox'), - pacApiUrl: z.string().url().max(255).optional(), - - // Certificate (base64 encoded) - certificate: z.string().optional(), - privateKey: z.string().optional(), - certificatePassword: z.string().max(255).optional(), - - // Defaults - defaultSerie: z.string().min(1).max(10).default('A'), - defaultFormaPago: formaPagoSchema.default('01'), - defaultMetodoPago: metodoPagoSchema.default('PUE'), - defaultUsoCFDI: usoCFDISchema.default('G03'), - defaultMoneda: monedaSchema.default('MXN'), - - // Branding - logoUrl: z.string().url().max(255).optional(), - pdfTemplate: z.string().max(50).default('standard'), - footerText: z.string().max(500).optional(), - - // Email settings - sendEmailOnIssue: z.boolean().default(true), - emailFrom: z.string().email().max(100).optional(), - emailSubjectTemplate: z.string().max(200).optional(), - emailBodyTemplate: z.string().max(2000).optional(), - - // Notifications - notifyCertificateExpiry: z.boolean().default(true), - notifyDaysBeforeExpiry: z.number().int().min(1).max(90).default(30), - - // Auto-invoicing - autoInvoicePOS: z.boolean().default(false), - autoInvoiceEcommerce: z.boolean().default(true), - - // Rate limiting - maxInvoicesPerDay: z.number().int().positive().optional(), -}); - -export const updateCFDIConfigSchema = createCFDIConfigSchema.partial(); - -export const validateCFDIConfigSchema = z.object({ - testStamp: z.boolean().default(false), -}); - -// ==================== AUTOFACTURA SCHEMAS ==================== - -export const autofacturaRequestSchema = z.object({ - orderNumber: z.string().min(1).max(30), - orderDate: z.coerce.date().optional(), - branchId: uuidSchema.optional(), - receptor: receptorSchema, -}); - -export const lookupOrderSchema = z.object({ - orderNumber: z.string().min(1).max(30), - orderDate: z.coerce.date().optional(), - orderAmount: moneySchema.positive().optional(), -}); - -// ==================== STATS QUERY SCHEMA ==================== - -export const cfdiStatsQuerySchema = z.object({ - startDate: z.coerce.date(), - endDate: z.coerce.date(), - branchId: uuidSchema.optional(), -}); - -// ==================== TYPES ==================== - -export type CreateCFDIInput = z.infer; -export type CancelCFDIInput = z.infer; -export type ListCFDIsQuery = z.infer; -export type ResendEmailInput = z.infer; -export type CreateCreditNoteInput = z.infer; -export type CreateCFDIConfigInput = z.infer; -export type UpdateCFDIConfigInput = z.infer; -export type AutofacturaRequest = z.infer; -export type LookupOrderInput = z.infer; -export type CFDIStatsQuery = z.infer; -export type ReceptorInput = z.infer; -export type OrderLineInput = z.infer; diff --git a/backend/src/modules/invoicing/validation/index.ts b/backend/src/modules/invoicing/validation/index.ts deleted file mode 100644 index 390a2e6..0000000 --- a/backend/src/modules/invoicing/validation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './cfdi.schema'; diff --git a/backend/src/modules/pos/controllers/pos.controller.ts b/backend/src/modules/pos/controllers/pos.controller.ts deleted file mode 100644 index 1f1d5d3..0000000 --- a/backend/src/modules/pos/controllers/pos.controller.ts +++ /dev/null @@ -1,605 +0,0 @@ -import { Response, NextFunction } from 'express'; -import { BaseController } from '../../../shared/controllers/base.controller'; -import { AuthenticatedRequest } from '../../../shared/types'; -import { posSessionService } from '../services/pos-session.service'; -import { posOrderService } from '../services/pos-order.service'; - -class POSController extends BaseController { - // ==================== SESSION ENDPOINTS ==================== - - /** - * POST /pos/sessions/open - Open a new session - */ - async openSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - const branchId = this.getBranchId(req); - - if (!branchId) { - return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); - } - - const { registerId, openingCash, openingNotes } = req.body; - - if (!registerId) { - return this.validationError(res, { registerId: 'Required' }); - } - - const result = await posSessionService.openSession(tenantId, { - branchId, - registerId, - userId, - openingCash: openingCash || 0, - openingNotes, - }); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data, 201); - } catch (error) { - next(error); - } - } - - /** - * POST /pos/sessions/:id/close - Close a session - */ - async closeSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - const { id } = req.params; - - const { closingCashCounted, closingNotes, cashCountDetail } = req.body; - - if (closingCashCounted === undefined) { - return this.validationError(res, { closingCashCounted: 'Required' }); - } - - const result = await posSessionService.closeSession(tenantId, id, { - closedBy: userId, - closingCashCounted, - closingNotes, - cashCountDetail, - }); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data); - } catch (error) { - next(error); - } - } - - /** - * GET /pos/sessions/active - Get active session for current user - */ - async getActiveSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - - const session = await posSessionService.getActiveSession(tenantId, userId); - - if (!session) { - return this.notFound(res, 'Active session'); - } - - return this.success(res, session); - } catch (error) { - next(error); - } - } - - /** - * GET /pos/sessions/:id - Get session by ID - */ - async getSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { id } = req.params; - const { withOrders } = req.query; - - const session = withOrders - ? await posSessionService.getSessionWithOrders(tenantId, id) - : await posSessionService.findById(tenantId, id); - - if (!session) { - return this.notFound(res, 'Session'); - } - - return this.success(res, session); - } catch (error) { - next(error); - } - } - - /** - * GET /pos/sessions - List sessions for branch - */ - async listSessions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const branchId = this.getBranchId(req); - - if (!branchId) { - return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); - } - - const pagination = this.parsePagination(req.query); - const { status, userId, startDate, endDate } = req.query; - - const result = await posSessionService.findAll(tenantId, { - pagination, - filters: [ - { field: 'branchId', operator: 'eq', value: branchId }, - ...(status ? [{ field: 'status', operator: 'eq' as const, value: status }] : []), - ...(userId ? [{ field: 'userId', operator: 'eq' as const, value: userId }] : []), - ], - }); - - return this.paginated(res, result); - } catch (error) { - next(error); - } - } - - /** - * GET /pos/sessions/daily-summary - Get daily session summary - */ - async getDailySummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const branchId = this.getBranchId(req); - - if (!branchId) { - return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); - } - - const dateParam = req.query.date as string; - const date = dateParam ? new Date(dateParam) : new Date(); - - const summary = await posSessionService.getDailySummary(tenantId, branchId, date); - return this.success(res, summary); - } catch (error) { - next(error); - } - } - - // ==================== ORDER ENDPOINTS ==================== - - /** - * POST /pos/orders - Create a new order - */ - async createOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - const branchId = this.getBranchId(req); - const branchCode = req.branch?.branchCode || 'UNK'; - - if (!branchId) { - return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); - } - - const { sessionId, registerId, lines, ...orderData } = req.body; - - if (!sessionId || !registerId || !lines || lines.length === 0) { - return this.validationError(res, { - sessionId: sessionId ? undefined : 'Required', - registerId: registerId ? undefined : 'Required', - lines: lines?.length ? undefined : 'At least one line is required', - }); - } - - const result = await posOrderService.createOrder( - tenantId, - { - sessionId, - branchId, - registerId, - userId, - lines, - ...orderData, - }, - branchCode - ); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data, 201); - } catch (error) { - next(error); - } - } - - /** - * POST /pos/orders/:id/payments - Add payment to order - */ - async addPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - const { id } = req.params; - - const { method, amount, amountReceived, ...paymentData } = req.body; - - if (!method || amount === undefined) { - return this.validationError(res, { - method: method ? undefined : 'Required', - amount: amount !== undefined ? undefined : 'Required', - }); - } - - const result = await posOrderService.addPayment( - tenantId, - id, - { - method, - amount, - amountReceived: amountReceived || amount, - ...paymentData, - }, - userId - ); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data); - } catch (error) { - next(error); - } - } - - /** - * POST /pos/orders/:id/void - Void an order - */ - async voidOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - const { id } = req.params; - const { reason } = req.body; - - if (!reason) { - return this.validationError(res, { reason: 'Required' }); - } - - const result = await posOrderService.voidOrder(tenantId, id, reason, userId); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data); - } catch (error) { - next(error); - } - } - - /** - * GET /pos/orders/:id - Get order by ID - */ - async getOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { id } = req.params; - - const order = await posOrderService.getOrderWithDetails(tenantId, id); - - if (!order) { - return this.notFound(res, 'Order'); - } - - return this.success(res, order); - } catch (error) { - next(error); - } - } - - /** - * GET /pos/orders - List orders - */ - async listOrders(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const branchId = this.getBranchId(req); - - if (!branchId) { - return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); - } - - const pagination = this.parsePagination(req.query); - const { sessionId, status, customerId, startDate, endDate, search } = req.query; - - // If search term provided, use search method - if (search) { - const orders = await posOrderService.searchOrders( - tenantId, - branchId, - search as string, - pagination.limit - ); - return this.success(res, orders); - } - - const result = await posOrderService.findAll(tenantId, { - pagination, - filters: [ - { field: 'branchId', operator: 'eq', value: branchId }, - ...(sessionId ? [{ field: 'sessionId', operator: 'eq' as const, value: sessionId }] : []), - ...(status ? [{ field: 'status', operator: 'eq' as const, value: status }] : []), - ...(customerId ? [{ field: 'customerId', operator: 'eq' as const, value: customerId }] : []), - ], - }); - - return this.paginated(res, result); - } catch (error) { - next(error); - } - } - - /** - * GET /pos/orders/session/:sessionId - Get orders by session - */ - async getOrdersBySession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { sessionId } = req.params; - - const orders = await posOrderService.getOrdersBySession(tenantId, sessionId); - return this.success(res, orders); - } catch (error) { - next(error); - } - } - - // ==================== REFUND ENDPOINTS ==================== - - /** - * POST /pos/refunds - Create a refund - */ - async createRefund(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - const branchId = this.getBranchId(req); - const branchCode = req.branch?.branchCode || 'UNK'; - - if (!branchId) { - return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); - } - - const { originalOrderId, sessionId, registerId, lines, refundReason } = req.body; - - if (!originalOrderId || !sessionId || !registerId || !lines || lines.length === 0) { - return this.validationError(res, { - originalOrderId: originalOrderId ? undefined : 'Required', - sessionId: sessionId ? undefined : 'Required', - registerId: registerId ? undefined : 'Required', - lines: lines?.length ? undefined : 'At least one line is required', - }); - } - - const result = await posOrderService.createRefund( - tenantId, - { - originalOrderId, - sessionId, - branchId, - registerId, - userId, - lines, - refundReason: refundReason || 'Customer refund', - }, - branchCode - ); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data, 201); - } catch (error) { - next(error); - } - } - - /** - * POST /pos/refunds/:id/process - Process refund payment - */ - async processRefund(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - const { id } = req.params; - const { method, amount } = req.body; - - if (!method || amount === undefined) { - return this.validationError(res, { - method: method ? undefined : 'Required', - amount: amount !== undefined ? undefined : 'Required', - }); - } - - const result = await posOrderService.processRefundPayment(tenantId, id, { method, amount }, userId); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data); - } catch (error) { - next(error); - } - } - - // ==================== HOLD/RECALL ENDPOINTS ==================== - - /** - * POST /pos/orders/:id/hold - Hold an order - */ - async holdOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { id } = req.params; - const { holdName } = req.body; - - const result = await posOrderService.holdOrder(tenantId, id, holdName || 'Unnamed'); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data); - } catch (error) { - next(error); - } - } - - /** - * GET /pos/orders/held - Get held orders - */ - async getHeldOrders(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const branchId = this.getBranchId(req); - - if (!branchId) { - return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); - } - - const orders = await posOrderService.getHeldOrders(tenantId, branchId); - return this.success(res, orders); - } catch (error) { - next(error); - } - } - - /** - * POST /pos/orders/:id/recall - Recall a held order - */ - async recallOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const userId = this.getUserId(req); - const { id } = req.params; - const { sessionId, registerId } = req.body; - - if (!sessionId || !registerId) { - return this.validationError(res, { - sessionId: sessionId ? undefined : 'Required', - registerId: registerId ? undefined : 'Required', - }); - } - - const result = await posOrderService.recallOrder(tenantId, id, sessionId, registerId, userId); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data); - } catch (error) { - next(error); - } - } - - // ==================== ADDITIONAL ENDPOINTS ==================== - - /** - * POST /pos/orders/:id/coupon - Apply coupon to order - */ - async applyCoupon(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const branchId = this.getBranchId(req); - const { id } = req.params; - const { couponCode } = req.body; - - if (!branchId) { - return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); - } - - if (!couponCode) { - return this.validationError(res, { couponCode: 'Required' }); - } - - const result = await posOrderService.applyCouponToOrder(tenantId, id, couponCode, branchId); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, result.data); - } catch (error) { - next(error); - } - } - - /** - * POST /pos/orders/:id/receipt/print - Mark receipt as printed - */ - async markReceiptPrinted(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { id } = req.params; - - await posOrderService.markReceiptPrinted(tenantId, id); - return this.success(res, { message: 'Receipt marked as printed' }); - } catch (error) { - next(error); - } - } - - /** - * POST /pos/orders/:id/receipt/send - Send receipt by email - */ - async sendReceipt(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { id } = req.params; - const { email } = req.body; - - if (!email) { - return this.validationError(res, { email: 'Required' }); - } - - const result = await posOrderService.sendReceiptEmail(tenantId, id, email); - - if (!result.success) { - return this.error(res, result.error.code, result.error.message); - } - - return this.success(res, { message: 'Receipt sent successfully' }); - } catch (error) { - next(error); - } - } - - /** - * GET /pos/sessions/:id/stats - Get session statistics - */ - async getSessionStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { - try { - const tenantId = this.getTenantId(req); - const { id } = req.params; - - const stats = await posOrderService.getSessionStats(tenantId, id); - return this.success(res, stats); - } catch (error) { - next(error); - } - } -} - -export const posController = new POSController(); diff --git a/backend/src/modules/pos/entities/index.ts b/backend/src/modules/pos/entities/index.ts deleted file mode 100644 index d489fed..0000000 --- a/backend/src/modules/pos/entities/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './pos-session.entity'; -export * from './pos-order.entity'; -export * from './pos-order-line.entity'; -export * from './pos-payment.entity'; diff --git a/backend/src/modules/pos/entities/pos-order-line.entity.ts b/backend/src/modules/pos/entities/pos-order-line.entity.ts deleted file mode 100644 index 9f63e55..0000000 --- a/backend/src/modules/pos/entities/pos-order-line.entity.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { POSOrder } from './pos-order.entity'; - -export enum LineStatus { - ACTIVE = 'active', - VOIDED = 'voided', - RETURNED = 'returned', -} - -@Entity('pos_order_lines', { schema: 'retail' }) -@Index(['tenantId', 'orderId']) -@Index(['tenantId', 'productId']) -export class POSOrderLine { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'order_id', type: 'uuid' }) - orderId: string; - - @Column({ name: 'line_number', type: 'int' }) - lineNumber: number; - - // Product info (from erp-core inventory) - @Column({ name: 'product_id', type: 'uuid' }) - productId: string; - - @Column({ name: 'product_code', length: 50 }) - productCode: string; - - @Column({ name: 'product_name', length: 200 }) - productName: string; - - @Column({ name: 'product_barcode', length: 50, nullable: true }) - productBarcode: string; - - // Variant info (if applicable) - @Column({ name: 'variant_id', type: 'uuid', nullable: true }) - variantId: string; - - @Column({ name: 'variant_name', length: 200, nullable: true }) - variantName: string; - - // Quantity - @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) - quantity: number; - - @Column({ name: 'uom_id', type: 'uuid', nullable: true }) - uomId: string; - - @Column({ name: 'uom_name', length: 20, default: 'PZA' }) - uomName: string; - - // Pricing - @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 }) - unitPrice: number; - - @Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 }) - originalPrice: number; - - @Column({ name: 'price_list_id', type: 'uuid', nullable: true }) - priceListId: string; - - // Line discounts - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) - discountPercent: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - discountAmount: number; - - @Column({ name: 'discount_reason', length: 255, nullable: true }) - discountReason: string; - - // Promotion applied - @Column({ name: 'promotion_id', type: 'uuid', nullable: true }) - promotionId: string; - - @Column({ name: 'promotion_name', length: 100, nullable: true }) - promotionName: string; - - @Column({ name: 'promotion_discount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - promotionDiscount: number; - - // Tax - @Column({ name: 'tax_id', type: 'uuid', nullable: true }) - taxId: string; - - @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) - taxRate: number; - - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; - - @Column({ name: 'tax_included', type: 'boolean', default: true }) - taxIncluded: boolean; - - // Totals - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - // Cost (for margin calculation) - @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) - unitCost: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) - margin: number; - - // Status - @Column({ - type: 'enum', - enum: LineStatus, - default: LineStatus.ACTIVE, - }) - status: LineStatus; - - // Lot/Serial tracking - @Column({ name: 'lot_number', length: 50, nullable: true }) - lotNumber: string; - - @Column({ name: 'serial_number', length: 50, nullable: true }) - serialNumber: string; - - @Column({ name: 'expiry_date', type: 'date', nullable: true }) - expiryDate: Date; - - // Warehouse location - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId: string; - - @Column({ name: 'location_id', type: 'uuid', nullable: true }) - locationId: string; - - // For returns/exchanges - @Column({ name: 'original_line_id', type: 'uuid', nullable: true }) - originalLineId: string; - - @Column({ name: 'return_reason', length: 255, nullable: true }) - returnReason: string; - - // Salesperson override (if different from order) - @Column({ name: 'salesperson_id', type: 'uuid', nullable: true }) - salespersonId: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - // Relations - @ManyToOne(() => POSOrder, (order) => order.lines) - @JoinColumn({ name: 'order_id' }) - order: POSOrder; -} diff --git a/backend/src/modules/pos/entities/pos-order.entity.ts b/backend/src/modules/pos/entities/pos-order.entity.ts deleted file mode 100644 index c1ad466..0000000 --- a/backend/src/modules/pos/entities/pos-order.entity.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - OneToMany, - JoinColumn, - Index, -} from 'typeorm'; -import { POSSession } from './pos-session.entity'; -import { POSOrderLine } from './pos-order-line.entity'; -import { POSPayment } from './pos-payment.entity'; - -export enum OrderStatus { - DRAFT = 'draft', - CONFIRMED = 'confirmed', - PAID = 'paid', - PARTIALLY_PAID = 'partially_paid', - VOIDED = 'voided', - REFUNDED = 'refunded', -} - -export enum OrderType { - SALE = 'sale', - REFUND = 'refund', - EXCHANGE = 'exchange', -} - -@Entity('pos_orders', { schema: 'retail' }) -@Index(['tenantId', 'branchId', 'createdAt']) -@Index(['tenantId', 'sessionId']) -@Index(['tenantId', 'customerId']) -@Index(['tenantId', 'number'], { unique: true }) -export class POSOrder { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - @Column({ name: 'session_id', type: 'uuid' }) - sessionId: string; - - @Column({ name: 'register_id', type: 'uuid' }) - registerId: string; - - @Column({ name: 'user_id', type: 'uuid' }) - userId: string; - - @Column({ length: 30, unique: true }) - number: string; - - @Column({ - type: 'enum', - enum: OrderType, - default: OrderType.SALE, - }) - type: OrderType; - - @Column({ - type: 'enum', - enum: OrderStatus, - default: OrderStatus.DRAFT, - }) - status: OrderStatus; - - // Customer (optional, from erp-core partners) - @Column({ name: 'customer_id', type: 'uuid', nullable: true }) - customerId: string; - - @Column({ name: 'customer_name', length: 200, nullable: true }) - customerName: string; - - @Column({ name: 'customer_rfc', length: 13, nullable: true }) - customerRfc: string; - - // Salesperson (for commission tracking) - @Column({ name: 'salesperson_id', type: 'uuid', nullable: true }) - salespersonId: string; - - // Amounts - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - discountAmount: number; - - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) - discountPercent: number; - - @Column({ name: 'discount_reason', length: 255, nullable: true }) - discountReason: string; - - @Column({ name: 'discount_authorized_by', type: 'uuid', nullable: true }) - discountAuthorizedBy: string; - - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - @Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 }) - amountPaid: number; - - @Column({ name: 'change_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - changeAmount: number; - - // Tax breakdown - @Column({ name: 'tax_breakdown', type: 'jsonb', nullable: true }) - taxBreakdown: { - taxId: string; - taxName: string; - rate: number; - base: number; - amount: number; - }[]; - - // Currency - @Column({ name: 'currency_code', length: 3, default: 'MXN' }) - currencyCode: string; - - @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) - exchangeRate: number; - - // Coupon/Promotion - @Column({ name: 'coupon_id', type: 'uuid', nullable: true }) - couponId: string; - - @Column({ name: 'coupon_code', length: 50, nullable: true }) - couponCode: string; - - @Column({ name: 'promotion_ids', type: 'jsonb', nullable: true }) - promotionIds: string[]; - - // Loyalty points - @Column({ name: 'loyalty_points_earned', type: 'int', default: 0 }) - loyaltyPointsEarned: number; - - @Column({ name: 'loyalty_points_redeemed', type: 'int', default: 0 }) - loyaltyPointsRedeemed: number; - - @Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - loyaltyPointsValue: number; - - // Invoicing - @Column({ name: 'requires_invoice', type: 'boolean', default: false }) - requiresInvoice: boolean; - - @Column({ name: 'cfdi_id', type: 'uuid', nullable: true }) - cfdiId: string; - - @Column({ name: 'invoice_status', length: 20, nullable: true }) - invoiceStatus: 'pending' | 'issued' | 'cancelled'; - - // Reference to original order (for refunds/exchanges) - @Column({ name: 'original_order_id', type: 'uuid', nullable: true }) - originalOrderId: string; - - // Void info - @Column({ name: 'voided_at', type: 'timestamp with time zone', nullable: true }) - voidedAt: Date; - - @Column({ name: 'voided_by', type: 'uuid', nullable: true }) - voidedBy: string; - - @Column({ name: 'void_reason', length: 255, nullable: true }) - voidReason: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Offline sync - @Column({ name: 'is_offline_order', type: 'boolean', default: false }) - isOfflineOrder: boolean; - - @Column({ name: 'offline_uuid', type: 'uuid', nullable: true }) - offlineUuid: string; - - @Column({ name: 'synced_at', type: 'timestamp with time zone', nullable: true }) - syncedAt: Date; - - // Receipt - @Column({ name: 'receipt_printed', type: 'boolean', default: false }) - receiptPrinted: boolean; - - @Column({ name: 'receipt_sent', type: 'boolean', default: false }) - receiptSent: boolean; - - @Column({ name: 'receipt_email', length: 100, nullable: true }) - receiptEmail: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'completed_at', type: 'timestamp with time zone', nullable: true }) - completedAt: Date; - - // Relations - @ManyToOne(() => POSSession, (session) => session.orders) - @JoinColumn({ name: 'session_id' }) - session: POSSession; - - @OneToMany(() => POSOrderLine, (line) => line.order) - lines: POSOrderLine[]; - - @OneToMany(() => POSPayment, (payment) => payment.order) - payments: POSPayment[]; -} diff --git a/backend/src/modules/pos/entities/pos-payment.entity.ts b/backend/src/modules/pos/entities/pos-payment.entity.ts deleted file mode 100644 index 73b6aeb..0000000 --- a/backend/src/modules/pos/entities/pos-payment.entity.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { POSOrder } from './pos-order.entity'; - -export enum PaymentMethod { - CASH = 'cash', - DEBIT_CARD = 'debit_card', - CREDIT_CARD = 'credit_card', - TRANSFER = 'transfer', - CHECK = 'check', - LOYALTY_POINTS = 'loyalty_points', - VOUCHER = 'voucher', - OTHER = 'other', -} - -export enum PaymentStatus { - PENDING = 'pending', - COMPLETED = 'completed', - FAILED = 'failed', - REFUNDED = 'refunded', - VOIDED = 'voided', -} - -@Entity('pos_payments', { schema: 'retail' }) -@Index(['tenantId', 'orderId']) -@Index(['tenantId', 'sessionId']) -@Index(['tenantId', 'createdAt']) -export class POSPayment { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'order_id', type: 'uuid' }) - orderId: string; - - @Column({ name: 'session_id', type: 'uuid' }) - sessionId: string; - - @Column({ - type: 'enum', - enum: PaymentMethod, - }) - method: PaymentMethod; - - @Column({ - type: 'enum', - enum: PaymentStatus, - default: PaymentStatus.PENDING, - }) - status: PaymentStatus; - - // Amount - @Column({ type: 'decimal', precision: 15, scale: 2 }) - amount: number; - - @Column({ name: 'amount_received', type: 'decimal', precision: 15, scale: 2 }) - amountReceived: number; - - @Column({ name: 'change_given', type: 'decimal', precision: 15, scale: 2, default: 0 }) - changeGiven: number; - - // Currency - @Column({ name: 'currency_code', length: 3, default: 'MXN' }) - currencyCode: string; - - @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) - exchangeRate: number; - - // Card payment details - @Column({ name: 'card_type', length: 20, nullable: true }) - cardType: string; // visa, mastercard, amex, etc. - - @Column({ name: 'card_last_four', length: 4, nullable: true }) - cardLastFour: string; - - @Column({ name: 'card_holder', length: 100, nullable: true }) - cardHolder: string; - - @Column({ name: 'authorization_code', length: 50, nullable: true }) - authorizationCode: string; - - @Column({ name: 'transaction_reference', length: 100, nullable: true }) - transactionReference: string; - - @Column({ name: 'terminal_id', length: 50, nullable: true }) - terminalId: string; - - // Transfer details - @Column({ name: 'bank_name', length: 100, nullable: true }) - bankName: string; - - @Column({ name: 'transfer_reference', length: 100, nullable: true }) - transferReference: string; - - // Check details - @Column({ name: 'check_number', length: 50, nullable: true }) - checkNumber: string; - - @Column({ name: 'check_bank', length: 100, nullable: true }) - checkBank: string; - - @Column({ name: 'check_date', type: 'date', nullable: true }) - checkDate: Date; - - // Voucher/Gift card - @Column({ name: 'voucher_code', length: 50, nullable: true }) - voucherCode: string; - - @Column({ name: 'voucher_id', type: 'uuid', nullable: true }) - voucherId: string; - - // Loyalty points - @Column({ name: 'loyalty_points_used', type: 'int', nullable: true }) - loyaltyPointsUsed: number; - - // Tips - @Column({ name: 'tip_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - tipAmount: number; - - // Financial account (for accounting integration) - @Column({ name: 'account_id', type: 'uuid', nullable: true }) - accountId: string; - - // Refund reference - @Column({ name: 'refund_of_payment_id', type: 'uuid', nullable: true }) - refundOfPaymentId: string; - - @Column({ name: 'refund_reason', length: 255, nullable: true }) - refundReason: string; - - // Processing info - @Column({ name: 'processed_at', type: 'timestamp with time zone', nullable: true }) - processedAt: Date; - - @Column({ name: 'processed_by', type: 'uuid', nullable: true }) - processedBy: string; - - // External payment gateway - @Column({ name: 'gateway_name', length: 50, nullable: true }) - gatewayName: string; - - @Column({ name: 'gateway_transaction_id', length: 100, nullable: true }) - gatewayTransactionId: string; - - @Column({ name: 'gateway_response', type: 'jsonb', nullable: true }) - gatewayResponse: Record; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - // Relations - @ManyToOne(() => POSOrder, (order) => order.payments) - @JoinColumn({ name: 'order_id' }) - order: POSOrder; -} diff --git a/backend/src/modules/pos/entities/pos-session.entity.ts b/backend/src/modules/pos/entities/pos-session.entity.ts deleted file mode 100644 index ede6eb2..0000000 --- a/backend/src/modules/pos/entities/pos-session.entity.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { POSOrder } from './pos-order.entity'; - -export enum SessionStatus { - OPEN = 'open', - CLOSING = 'closing', - CLOSED = 'closed', - RECONCILED = 'reconciled', -} - -@Entity('pos_sessions', { schema: 'retail' }) -@Index(['tenantId', 'branchId', 'status']) -@Index(['tenantId', 'registerId', 'status']) -@Index(['tenantId', 'openedAt']) -export class POSSession { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - @Column({ name: 'register_id', type: 'uuid' }) - registerId: string; - - @Column({ name: 'user_id', type: 'uuid' }) - userId: string; - - @Column({ length: 30, unique: true }) - number: string; - - @Column({ - type: 'enum', - enum: SessionStatus, - default: SessionStatus.OPEN, - }) - status: SessionStatus; - - // Opening - @Column({ name: 'opened_at', type: 'timestamp with time zone' }) - openedAt: Date; - - @Column({ name: 'opening_cash', type: 'decimal', precision: 15, scale: 2, default: 0 }) - openingCash: number; - - @Column({ name: 'opening_notes', type: 'text', nullable: true }) - openingNotes: string; - - // Closing - @Column({ name: 'closed_at', type: 'timestamp with time zone', nullable: true }) - closedAt: Date; - - @Column({ name: 'closing_cash_counted', type: 'decimal', precision: 15, scale: 2, nullable: true }) - closingCashCounted: number; - - @Column({ name: 'closing_cash_expected', type: 'decimal', precision: 15, scale: 2, nullable: true }) - closingCashExpected: number; - - @Column({ name: 'closing_difference', type: 'decimal', precision: 15, scale: 2, nullable: true }) - closingDifference: number; - - @Column({ name: 'closing_notes', type: 'text', nullable: true }) - closingNotes: string; - - @Column({ name: 'closed_by', type: 'uuid', nullable: true }) - closedBy: string; - - // Session totals (denormalized for performance) - @Column({ name: 'total_sales', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalSales: number; - - @Column({ name: 'total_refunds', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalRefunds: number; - - @Column({ name: 'total_discounts', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalDiscounts: number; - - @Column({ name: 'orders_count', type: 'int', default: 0 }) - ordersCount: number; - - @Column({ name: 'items_sold', type: 'int', default: 0 }) - itemsSold: number; - - // Payment totals by method - @Column({ name: 'cash_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) - cashTotal: number; - - @Column({ name: 'card_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) - cardTotal: number; - - @Column({ name: 'transfer_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) - transferTotal: number; - - @Column({ name: 'other_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) - otherTotal: number; - - // Cash movements during session - @Column({ name: 'cash_in_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) - cashInTotal: number; - - @Column({ name: 'cash_out_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) - cashOutTotal: number; - - // Detailed cash count (closing) - @Column({ name: 'cash_count_detail', type: 'jsonb', nullable: true }) - cashCountDetail: { - bills: { denomination: number; count: number; total: number }[]; - coins: { denomination: number; count: number; total: number }[]; - total: number; - }; - - // Offline sync - @Column({ name: 'is_offline_session', type: 'boolean', default: false }) - isOfflineSession: boolean; - - @Column({ name: 'synced_at', type: 'timestamp with time zone', nullable: true }) - syncedAt: Date; - - @Column({ name: 'device_uuid', type: 'uuid', nullable: true }) - deviceUuid: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - // Relations - @OneToMany(() => POSOrder, (order) => order.session) - orders: POSOrder[]; -} diff --git a/backend/src/modules/pos/index.ts b/backend/src/modules/pos/index.ts deleted file mode 100644 index 561e3a1..0000000 --- a/backend/src/modules/pos/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './entities'; -export * from './services'; -export { default as posRoutes } from './routes/pos.routes'; diff --git a/backend/src/modules/pos/routes/pos.routes.ts b/backend/src/modules/pos/routes/pos.routes.ts deleted file mode 100644 index 76a0fd3..0000000 --- a/backend/src/modules/pos/routes/pos.routes.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Router } from 'express'; -import { posController } from '../controllers/pos.controller'; -import { authMiddleware, requireRoles } from '../../../shared/middleware/auth.middleware'; -import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware'; -import { branchMiddleware, validateBranchMiddleware, requireBranchCapability } from '../../../shared/middleware/branch.middleware'; -import { AuthenticatedRequest } from '../../../shared/types'; - -const router = Router(); - -// All routes require tenant and authentication -router.use(tenantMiddleware); -router.use(authMiddleware); - -// ==================== SESSION ROUTES ==================== - -// Get active session for current user (no branch required) -router.get('/sessions/active', (req, res, next) => - posController.getActiveSession(req as AuthenticatedRequest, res, next) -); - -// Routes that require branch context -router.use('/sessions', branchMiddleware); -router.use('/sessions', validateBranchMiddleware); -router.use('/sessions', requireBranchCapability('pos')); - -// Open session -router.post('/sessions/open', (req, res, next) => - posController.openSession(req as AuthenticatedRequest, res, next) -); - -// Close session -router.post('/sessions/:id/close', (req, res, next) => - posController.closeSession(req as AuthenticatedRequest, res, next) -); - -// Get daily summary -router.get('/sessions/daily-summary', (req, res, next) => - posController.getDailySummary(req as AuthenticatedRequest, res, next) -); - -// List sessions -router.get('/sessions', (req, res, next) => - posController.listSessions(req as AuthenticatedRequest, res, next) -); - -// Get session by ID -router.get('/sessions/:id', (req, res, next) => - posController.getSession(req as AuthenticatedRequest, res, next) -); - -// ==================== ORDER ROUTES ==================== - -// Routes that require branch context -router.use('/orders', branchMiddleware); -router.use('/orders', validateBranchMiddleware); -router.use('/orders', requireBranchCapability('pos')); - -// Create order -router.post('/orders', (req, res, next) => - posController.createOrder(req as AuthenticatedRequest, res, next) -); - -// List orders -router.get('/orders', (req, res, next) => - posController.listOrders(req as AuthenticatedRequest, res, next) -); - -// Get orders by session -router.get('/orders/session/:sessionId', (req, res, next) => - posController.getOrdersBySession(req as AuthenticatedRequest, res, next) -); - -// Get order by ID -router.get('/orders/:id', (req, res, next) => - posController.getOrder(req as AuthenticatedRequest, res, next) -); - -// Add payment -router.post('/orders/:id/payments', (req, res, next) => - posController.addPayment(req as AuthenticatedRequest, res, next) -); - -// Void order (requires supervisor or higher) -router.post( - '/orders/:id/void', - requireRoles('admin', 'manager', 'supervisor'), - (req, res, next) => posController.voidOrder(req as AuthenticatedRequest, res, next) -); - -// Hold order -router.post('/orders/:id/hold', (req, res, next) => - posController.holdOrder(req as AuthenticatedRequest, res, next) -); - -// Get held orders -router.get('/orders/held', (req, res, next) => - posController.getHeldOrders(req as AuthenticatedRequest, res, next) -); - -// Recall held order -router.post('/orders/:id/recall', (req, res, next) => - posController.recallOrder(req as AuthenticatedRequest, res, next) -); - -// Apply coupon to order -router.post('/orders/:id/coupon', (req, res, next) => - posController.applyCoupon(req as AuthenticatedRequest, res, next) -); - -// Mark receipt as printed -router.post('/orders/:id/receipt/print', (req, res, next) => - posController.markReceiptPrinted(req as AuthenticatedRequest, res, next) -); - -// Send receipt by email -router.post('/orders/:id/receipt/send', (req, res, next) => - posController.sendReceipt(req as AuthenticatedRequest, res, next) -); - -// ==================== REFUND ROUTES ==================== - -// Create refund -router.post( - '/refunds', - requireRoles('admin', 'manager', 'supervisor'), - (req, res, next) => posController.createRefund(req as AuthenticatedRequest, res, next) -); - -// Process refund payment -router.post( - '/refunds/:id/process', - requireRoles('admin', 'manager', 'supervisor'), - (req, res, next) => posController.processRefund(req as AuthenticatedRequest, res, next) -); - -// ==================== SESSION STATS ==================== - -// Get session statistics -router.get('/sessions/:id/stats', (req, res, next) => - posController.getSessionStats(req as AuthenticatedRequest, res, next) -); - -export default router; diff --git a/backend/src/modules/pos/services/index.ts b/backend/src/modules/pos/services/index.ts deleted file mode 100644 index 8f4d71e..0000000 --- a/backend/src/modules/pos/services/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './pos-session.service'; -export * from './pos-order.service'; diff --git a/backend/src/modules/pos/services/pos-order.service.ts b/backend/src/modules/pos/services/pos-order.service.ts deleted file mode 100644 index 17fc7f3..0000000 --- a/backend/src/modules/pos/services/pos-order.service.ts +++ /dev/null @@ -1,914 +0,0 @@ -import { Repository, DeepPartial } from 'typeorm'; -import { AppDataSource } from '../../../config/typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult } from '../../../shared/types'; -import { POSOrder, OrderStatus, OrderType } from '../entities/pos-order.entity'; -import { POSOrderLine, LineStatus } from '../entities/pos-order-line.entity'; -import { POSPayment, PaymentMethod, PaymentStatus } from '../entities/pos-payment.entity'; -import { posSessionService } from './pos-session.service'; - -interface CreateOrderDTO { - sessionId: string; - branchId: string; - registerId: string; - userId: string; - customerId?: string; - customerName?: string; - customerRfc?: string; - lines: { - productId: string; - productCode: string; - productName: string; - productBarcode?: string; - quantity: number; - unitPrice: number; - originalPrice: number; - discountPercent?: number; - discountAmount?: number; - taxRate: number; - taxIncluded?: boolean; - }[]; - discountPercent?: number; - discountAmount?: number; - discountReason?: string; - requiresInvoice?: boolean; - notes?: string; -} - -interface AddPaymentDTO { - method: PaymentMethod; - amount: number; - amountReceived: number; - cardType?: string; - cardLastFour?: string; - authorizationCode?: string; - transactionReference?: string; -} - -export class POSOrderService extends BaseService { - private lineRepository: Repository; - private paymentRepository: Repository; - - constructor() { - super(AppDataSource.getRepository(POSOrder)); - this.lineRepository = AppDataSource.getRepository(POSOrderLine); - this.paymentRepository = AppDataSource.getRepository(POSPayment); - } - - /** - * Generate order number - */ - private generateOrderNumber(branchCode: string): string { - const date = new Date(); - const dateStr = date.toISOString().slice(0, 10).replace(/-/g, ''); - const random = Math.random().toString(36).substring(2, 8).toUpperCase(); - return `ORD-${branchCode}-${dateStr}-${random}`; - } - - /** - * Create a new POS order - */ - async createOrder( - tenantId: string, - data: CreateOrderDTO, - branchCode: string - ): Promise> { - const queryRunner = AppDataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - // Calculate totals - let subtotal = 0; - let totalTax = 0; - const taxBreakdown: POSOrder['taxBreakdown'] = []; - - const linesToCreate: DeepPartial[] = data.lines.map((line, index) => { - const lineSubtotal = line.quantity * line.unitPrice; - const lineDiscount = - line.discountAmount || lineSubtotal * ((line.discountPercent || 0) / 100); - const lineAfterDiscount = lineSubtotal - lineDiscount; - - let lineTax: number; - if (line.taxIncluded !== false) { - // Tax included in price - lineTax = lineAfterDiscount - lineAfterDiscount / (1 + line.taxRate); - } else { - // Tax not included - lineTax = lineAfterDiscount * line.taxRate; - } - - subtotal += lineSubtotal; - totalTax += lineTax; - - return { - tenantId, - lineNumber: index + 1, - productId: line.productId, - productCode: line.productCode, - productName: line.productName, - productBarcode: line.productBarcode, - quantity: line.quantity, - unitPrice: line.unitPrice, - originalPrice: line.originalPrice, - discountPercent: line.discountPercent || 0, - discountAmount: lineDiscount, - taxRate: line.taxRate, - taxAmount: lineTax, - taxIncluded: line.taxIncluded !== false, - subtotal: lineAfterDiscount, - total: line.taxIncluded !== false ? lineAfterDiscount : lineAfterDiscount + lineTax, - status: LineStatus.ACTIVE, - }; - }); - - // Order-level discount - const orderDiscount = - data.discountAmount || subtotal * ((data.discountPercent || 0) / 100); - const total = subtotal - orderDiscount; - - // Create order - const order = this.repository.create({ - tenantId, - branchId: data.branchId, - sessionId: data.sessionId, - registerId: data.registerId, - userId: data.userId, - number: this.generateOrderNumber(branchCode), - type: OrderType.SALE, - status: OrderStatus.DRAFT, - customerId: data.customerId, - customerName: data.customerName, - customerRfc: data.customerRfc, - subtotal, - discountAmount: orderDiscount, - discountPercent: data.discountPercent || 0, - discountReason: data.discountReason, - taxAmount: totalTax, - total, - amountPaid: 0, - changeAmount: 0, - requiresInvoice: data.requiresInvoice || false, - notes: data.notes, - taxBreakdown, - }); - - const savedOrder = await queryRunner.manager.save(order); - - // Create lines - const lines = linesToCreate.map((line) => ({ - ...line, - orderId: savedOrder.id, - })); - await queryRunner.manager.save(POSOrderLine, lines); - - await queryRunner.commitTransaction(); - - // Fetch complete order - const completeOrder = await this.repository.findOne({ - where: { id: savedOrder.id }, - relations: ['lines'], - }); - - return { success: true, data: completeOrder! }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_ORDER_FAILED', - message: error.message || 'Failed to create order', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Add payment to order - */ - async addPayment( - tenantId: string, - orderId: string, - payment: AddPaymentDTO, - userId: string - ): Promise> { - const queryRunner = AppDataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const order = await this.repository.findOne({ - where: { id: orderId, tenantId }, - relations: ['payments'], - }); - - if (!order) { - return { - success: false, - error: { - code: 'ORDER_NOT_FOUND', - message: 'Order not found', - }, - }; - } - - if (order.status === OrderStatus.PAID) { - return { - success: false, - error: { - code: 'ORDER_ALREADY_PAID', - message: 'Order is already fully paid', - }, - }; - } - - // Calculate remaining amount - const remaining = order.total - order.amountPaid; - - // Determine actual payment amount - const actualAmount = Math.min(payment.amount, remaining); - const change = - payment.method === PaymentMethod.CASH - ? Math.max(0, payment.amountReceived - remaining) - : 0; - - // Create payment record - const paymentRecord = this.paymentRepository.create({ - tenantId, - orderId: order.id, - sessionId: order.sessionId, - method: payment.method, - status: PaymentStatus.COMPLETED, - amount: actualAmount, - amountReceived: payment.amountReceived, - changeGiven: change, - cardType: payment.cardType, - cardLastFour: payment.cardLastFour, - authorizationCode: payment.authorizationCode, - transactionReference: payment.transactionReference, - processedAt: new Date(), - processedBy: userId, - }); - - await queryRunner.manager.save(paymentRecord); - - // Update order - order.amountPaid += actualAmount; - order.changeAmount += change; - - if (order.amountPaid >= order.total) { - order.status = OrderStatus.PAID; - order.completedAt = new Date(); - } else { - order.status = OrderStatus.PARTIALLY_PAID; - } - - await queryRunner.manager.save(order); - - // Update session totals - await posSessionService.updateSessionTotals(order.sessionId, { - total: actualAmount, - discounts: 0, - cashAmount: payment.method === PaymentMethod.CASH ? actualAmount : 0, - cardAmount: - payment.method === PaymentMethod.CREDIT_CARD || - payment.method === PaymentMethod.DEBIT_CARD - ? actualAmount - : 0, - transferAmount: payment.method === PaymentMethod.TRANSFER ? actualAmount : 0, - otherAmount: - ![ - PaymentMethod.CASH, - PaymentMethod.CREDIT_CARD, - PaymentMethod.DEBIT_CARD, - PaymentMethod.TRANSFER, - ].includes(payment.method) - ? actualAmount - : 0, - itemsCount: order.status === OrderStatus.PAID ? order.lines?.length || 0 : 0, - isRefund: false, - }); - - await queryRunner.commitTransaction(); - - // Fetch updated order - const updatedOrder = await this.repository.findOne({ - where: { id: orderId }, - relations: ['lines', 'payments'], - }); - - return { success: true, data: updatedOrder! }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'ADD_PAYMENT_FAILED', - message: error.message || 'Failed to add payment', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Void an order - */ - async voidOrder( - tenantId: string, - orderId: string, - reason: string, - userId: string - ): Promise> { - const order = await this.repository.findOne({ - where: { id: orderId, tenantId }, - }); - - if (!order) { - return { - success: false, - error: { - code: 'ORDER_NOT_FOUND', - message: 'Order not found', - }, - }; - } - - if (order.status === OrderStatus.VOIDED) { - return { - success: false, - error: { - code: 'ORDER_ALREADY_VOIDED', - message: 'Order is already voided', - }, - }; - } - - if (order.cfdiId) { - return { - success: false, - error: { - code: 'ORDER_HAS_INVOICE', - message: 'Cannot void order with issued invoice', - }, - }; - } - - order.status = OrderStatus.VOIDED; - order.voidedAt = new Date(); - order.voidedBy = userId; - order.voidReason = reason; - - const savedOrder = await this.repository.save(order); - return { success: true, data: savedOrder }; - } - - /** - * Get order with all details - */ - async getOrderWithDetails(tenantId: string, orderId: string): Promise { - return this.repository.findOne({ - where: { id: orderId, tenantId }, - relations: ['lines', 'payments'], - }); - } - - /** - * Get orders by session - */ - async getOrdersBySession(tenantId: string, sessionId: string): Promise { - return this.repository.find({ - where: { tenantId, sessionId }, - relations: ['lines', 'payments'], - order: { createdAt: 'DESC' }, - }); - } - - /** - * Search orders by customer or number - */ - async searchOrders( - tenantId: string, - branchId: string, - term: string, - limit: number = 20 - ): Promise { - return this.repository - .createQueryBuilder('order') - .where('order.tenantId = :tenantId', { tenantId }) - .andWhere('order.branchId = :branchId', { branchId }) - .andWhere( - '(order.number ILIKE :term OR order.customerName ILIKE :term OR order.customerRfc ILIKE :term)', - { term: `%${term}%` } - ) - .orderBy('order.createdAt', 'DESC') - .limit(limit) - .getMany(); - } - - /** - * Create a refund order - */ - async createRefund( - tenantId: string, - data: { - originalOrderId: string; - sessionId: string; - branchId: string; - registerId: string; - userId: string; - lines: { - originalLineId: string; - quantity: number; - reason: string; - }[]; - refundReason: string; - }, - branchCode: string - ): Promise> { - const queryRunner = AppDataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - // Get original order - const originalOrder = await this.repository.findOne({ - where: { id: data.originalOrderId, tenantId }, - relations: ['lines', 'payments'], - }); - - if (!originalOrder) { - return { - success: false, - error: { - code: 'ORIGINAL_ORDER_NOT_FOUND', - message: 'Original order not found', - }, - }; - } - - if (originalOrder.status !== OrderStatus.PAID) { - return { - success: false, - error: { - code: 'ORDER_NOT_PAID', - message: 'Can only refund paid orders', - }, - }; - } - - // Build refund lines - let refundTotal = 0; - let refundTax = 0; - const refundLines: DeepPartial[] = []; - - for (const refundLine of data.lines) { - const originalLine = originalOrder.lines?.find(l => l.id === refundLine.originalLineId); - if (!originalLine) { - return { - success: false, - error: { - code: 'LINE_NOT_FOUND', - message: `Original line ${refundLine.originalLineId} not found`, - }, - }; - } - - if (refundLine.quantity > originalLine.quantity) { - return { - success: false, - error: { - code: 'INVALID_QUANTITY', - message: `Cannot refund more than original quantity`, - }, - }; - } - - const lineSubtotal = refundLine.quantity * originalLine.unitPrice; - const lineDiscount = (originalLine.discountAmount / originalLine.quantity) * refundLine.quantity; - const lineTax = (originalLine.taxAmount / originalLine.quantity) * refundLine.quantity; - const lineTotal = lineSubtotal - lineDiscount; - - refundTotal += lineTotal; - refundTax += lineTax; - - refundLines.push({ - tenantId, - lineNumber: refundLines.length + 1, - productId: originalLine.productId, - productCode: originalLine.productCode, - productName: originalLine.productName, - quantity: -refundLine.quantity, - unitPrice: originalLine.unitPrice, - originalPrice: originalLine.originalPrice, - discountPercent: originalLine.discountPercent, - discountAmount: lineDiscount, - taxRate: originalLine.taxRate, - taxAmount: -lineTax, - subtotal: -lineSubtotal, - total: -lineTotal, - status: LineStatus.ACTIVE, - notes: refundLine.reason, - }); - } - - // Create refund order - const refundOrder = this.repository.create({ - tenantId, - branchId: data.branchId, - sessionId: data.sessionId, - registerId: data.registerId, - userId: data.userId, - number: this.generateOrderNumber(branchCode).replace('ORD', 'REF'), - type: OrderType.REFUND, - status: OrderStatus.DRAFT, - customerId: originalOrder.customerId, - customerName: originalOrder.customerName, - customerRfc: originalOrder.customerRfc, - subtotal: -refundTotal, - taxAmount: -refundTax, - total: -refundTotal, - originalOrderId: originalOrder.id, - notes: data.refundReason, - }); - - const savedRefund = await queryRunner.manager.save(refundOrder); - - // Create refund lines - const lines = refundLines.map((line) => ({ - ...line, - orderId: savedRefund.id, - })); - await queryRunner.manager.save(POSOrderLine, lines); - - await queryRunner.commitTransaction(); - - return { - success: true, - data: await this.repository.findOne({ - where: { id: savedRefund.id }, - relations: ['lines'], - }) as POSOrder, - }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_REFUND_FAILED', - message: error.message || 'Failed to create refund', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Process refund payment (cash out) - */ - async processRefundPayment( - tenantId: string, - refundOrderId: string, - payment: { - method: PaymentMethod; - amount: number; - }, - userId: string - ): Promise> { - const queryRunner = AppDataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const refundOrder = await this.repository.findOne({ - where: { id: refundOrderId, tenantId, type: OrderType.REFUND }, - relations: ['payments'], - }); - - if (!refundOrder) { - return { - success: false, - error: { - code: 'REFUND_NOT_FOUND', - message: 'Refund order not found', - }, - }; - } - - if (refundOrder.status === OrderStatus.PAID) { - return { - success: false, - error: { - code: 'REFUND_ALREADY_PROCESSED', - message: 'Refund has already been processed', - }, - }; - } - - // Create payment record (negative) - const paymentRecord = this.paymentRepository.create({ - tenantId, - orderId: refundOrder.id, - sessionId: refundOrder.sessionId, - method: payment.method, - status: PaymentStatus.COMPLETED, - amount: -Math.abs(payment.amount), - amountReceived: 0, - changeGiven: Math.abs(payment.amount), - processedAt: new Date(), - processedBy: userId, - }); - - await queryRunner.manager.save(paymentRecord); - - // Update refund order - refundOrder.amountPaid = -Math.abs(payment.amount); - refundOrder.status = OrderStatus.PAID; - refundOrder.completedAt = new Date(); - - await queryRunner.manager.save(refundOrder); - - // Update session totals (negative for refund) - await posSessionService.updateSessionTotals(refundOrder.sessionId, { - total: refundOrder.total, - discounts: 0, - cashAmount: payment.method === PaymentMethod.CASH ? -Math.abs(payment.amount) : 0, - cardAmount: 0, - transferAmount: 0, - otherAmount: 0, - itemsCount: 0, - isRefund: true, - }); - - await queryRunner.commitTransaction(); - - return { - success: true, - data: await this.repository.findOne({ - where: { id: refundOrderId }, - relations: ['lines', 'payments'], - }) as POSOrder, - }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'PROCESS_REFUND_FAILED', - message: error.message || 'Failed to process refund', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Hold an order for later (park) - */ - async holdOrder( - tenantId: string, - orderId: string, - holdName: string - ): Promise> { - const order = await this.repository.findOne({ - where: { id: orderId, tenantId }, - }); - - if (!order) { - return { - success: false, - error: { - code: 'ORDER_NOT_FOUND', - message: 'Order not found', - }, - }; - } - - if (order.status !== OrderStatus.DRAFT) { - return { - success: false, - error: { - code: 'CANNOT_HOLD', - message: 'Only draft orders can be held', - }, - }; - } - - order.status = OrderStatus.CONFIRMED; // Using confirmed as "on hold" - order.metadata = { - ...order.metadata, - isHeld: true, - holdName, - heldAt: new Date().toISOString(), - }; - - const savedOrder = await this.repository.save(order); - return { success: true, data: savedOrder }; - } - - /** - * Get held orders - */ - async getHeldOrders(tenantId: string, branchId: string): Promise { - return this.repository - .createQueryBuilder('order') - .where('order.tenantId = :tenantId', { tenantId }) - .andWhere('order.branchId = :branchId', { branchId }) - .andWhere('order.status = :status', { status: OrderStatus.CONFIRMED }) - .andWhere("order.metadata->>'isHeld' = 'true'") - .leftJoinAndSelect('order.lines', 'lines') - .orderBy('order.createdAt', 'DESC') - .getMany(); - } - - /** - * Recall a held order - */ - async recallOrder( - tenantId: string, - orderId: string, - sessionId: string, - registerId: string, - userId: string - ): Promise> { - const order = await this.repository.findOne({ - where: { id: orderId, tenantId }, - relations: ['lines'], - }); - - if (!order) { - return { - success: false, - error: { - code: 'ORDER_NOT_FOUND', - message: 'Order not found', - }, - }; - } - - if (!order.metadata?.isHeld) { - return { - success: false, - error: { - code: 'ORDER_NOT_HELD', - message: 'Order is not on hold', - }, - }; - } - - // Update to current session - order.sessionId = sessionId; - order.registerId = registerId; - order.userId = userId; - order.status = OrderStatus.DRAFT; - order.metadata = { - ...order.metadata, - isHeld: false, - recalledAt: new Date().toISOString(), - }; - - const savedOrder = await this.repository.save(order); - return { success: true, data: savedOrder }; - } - - /** - * Apply coupon to order using pricing engine - */ - async applyCouponToOrder( - tenantId: string, - orderId: string, - couponCode: string, - branchId: string - ): Promise> { - const order = await this.repository.findOne({ - where: { id: orderId, tenantId }, - relations: ['lines'], - }); - - if (!order) { - return { - success: false, - error: { - code: 'ORDER_NOT_FOUND', - message: 'Order not found', - }, - }; - } - - if (order.status !== OrderStatus.DRAFT) { - return { - success: false, - error: { - code: 'ORDER_NOT_DRAFT', - message: 'Can only apply coupons to draft orders', - }, - }; - } - - // TODO: Integrate with PriceEngineService - // For now, just store the coupon code - order.couponCode = couponCode; - order.metadata = { - ...order.metadata, - couponAppliedAt: new Date().toISOString(), - }; - - const savedOrder = await this.repository.save(order); - return { success: true, data: savedOrder }; - } - - /** - * Mark receipt as printed - */ - async markReceiptPrinted(tenantId: string, orderId: string): Promise { - await this.repository.update( - { id: orderId, tenantId }, - { receiptPrinted: true } - ); - } - - /** - * Send receipt by email - */ - async sendReceiptEmail( - tenantId: string, - orderId: string, - email: string - ): Promise> { - const order = await this.repository.findOne({ - where: { id: orderId, tenantId }, - }); - - if (!order) { - return { - success: false, - error: { - code: 'ORDER_NOT_FOUND', - message: 'Order not found', - }, - }; - } - - // TODO: Implement email sending - // Update receipt status - await this.repository.update(orderId, { - receiptSent: true, - receiptEmail: email, - }); - - return { success: true }; - } - - /** - * Get order statistics for session - */ - async getSessionStats(tenantId: string, sessionId: string): Promise<{ - totalOrders: number; - totalSales: number; - totalRefunds: number; - totalDiscounts: number; - averageTicket: number; - paymentBreakdown: Record; - }> { - const orders = await this.repository.find({ - where: { tenantId, sessionId }, - relations: ['payments'], - }); - - const salesOrders = orders.filter(o => o.type === OrderType.SALE && o.status === OrderStatus.PAID); - const refundOrders = orders.filter(o => o.type === OrderType.REFUND && o.status === OrderStatus.PAID); - - const totalSales = salesOrders.reduce((sum, o) => sum + Number(o.total), 0); - const totalRefunds = Math.abs(refundOrders.reduce((sum, o) => sum + Number(o.total), 0)); - const totalDiscounts = salesOrders.reduce((sum, o) => sum + Number(o.discountAmount), 0); - - // Payment breakdown - const paymentBreakdown: Record = {}; - for (const order of salesOrders) { - for (const payment of (order.payments || [])) { - paymentBreakdown[payment.method] = (paymentBreakdown[payment.method] || 0) + Number(payment.amount); - } - } - - return { - totalOrders: salesOrders.length, - totalSales, - totalRefunds, - totalDiscounts, - averageTicket: salesOrders.length > 0 ? totalSales / salesOrders.length : 0, - paymentBreakdown, - }; - } -} - -// Export singleton instance -export const posOrderService = new POSOrderService(); diff --git a/backend/src/modules/pos/services/pos-session.service.ts b/backend/src/modules/pos/services/pos-session.service.ts deleted file mode 100644 index aa3e4cc..0000000 --- a/backend/src/modules/pos/services/pos-session.service.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { Repository, DeepPartial } from 'typeorm'; -import { AppDataSource } from '../../../config/typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult } from '../../../shared/types'; -import { POSSession, SessionStatus } from '../entities/pos-session.entity'; -import { CashRegister, RegisterStatus } from '../../branches/entities/cash-register.entity'; - -export class POSSessionService extends BaseService { - private registerRepository: Repository; - - constructor() { - super(AppDataSource.getRepository(POSSession)); - this.registerRepository = AppDataSource.getRepository(CashRegister); - } - - /** - * Generate session number - */ - private generateSessionNumber(branchCode: string): string { - const date = new Date(); - const dateStr = date.toISOString().slice(0, 10).replace(/-/g, ''); - const timeStr = date.toISOString().slice(11, 19).replace(/:/g, ''); - return `SES-${branchCode}-${dateStr}-${timeStr}`; - } - - /** - * Open a new POS session - */ - async openSession( - tenantId: string, - data: { - branchId: string; - registerId: string; - userId: string; - openingCash: number; - openingNotes?: string; - } - ): Promise> { - const queryRunner = AppDataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - // Check if register exists and is available - const register = await this.registerRepository.findOne({ - where: { id: data.registerId, tenantId, branchId: data.branchId }, - relations: ['branch'], - }); - - if (!register) { - return { - success: false, - error: { - code: 'REGISTER_NOT_FOUND', - message: 'Cash register not found', - }, - }; - } - - if (register.status !== RegisterStatus.AVAILABLE) { - return { - success: false, - error: { - code: 'REGISTER_NOT_AVAILABLE', - message: `Cash register is currently ${register.status}`, - }, - }; - } - - // Check if user already has an open session - const existingSession = await this.repository.findOne({ - where: { - tenantId, - userId: data.userId, - status: SessionStatus.OPEN, - }, - }); - - if (existingSession) { - return { - success: false, - error: { - code: 'SESSION_ALREADY_OPEN', - message: 'User already has an open session', - }, - }; - } - - // Create session - const branchCode = register.branch?.code || 'UNK'; - const session = this.repository.create({ - tenantId, - branchId: data.branchId, - registerId: data.registerId, - userId: data.userId, - number: this.generateSessionNumber(branchCode), - status: SessionStatus.OPEN, - openedAt: new Date(), - openingCash: data.openingCash, - openingNotes: data.openingNotes, - }); - - const savedSession = await queryRunner.manager.save(session); - - // Update register status - await queryRunner.manager.update(CashRegister, register.id, { - status: RegisterStatus.IN_USE, - currentSessionId: savedSession.id, - currentUserId: data.userId, - }); - - await queryRunner.commitTransaction(); - - return { success: true, data: savedSession }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'OPEN_SESSION_FAILED', - message: error.message || 'Failed to open session', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Close a POS session - */ - async closeSession( - tenantId: string, - sessionId: string, - data: { - closedBy: string; - closingCashCounted: number; - closingNotes?: string; - cashCountDetail?: POSSession['cashCountDetail']; - } - ): Promise> { - const queryRunner = AppDataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const session = await this.repository.findOne({ - where: { id: sessionId, tenantId }, - }); - - if (!session) { - return { - success: false, - error: { - code: 'SESSION_NOT_FOUND', - message: 'Session not found', - }, - }; - } - - if (session.status !== SessionStatus.OPEN) { - return { - success: false, - error: { - code: 'SESSION_NOT_OPEN', - message: 'Session is not open', - }, - }; - } - - // Calculate expected cash - const expectedCash = - session.openingCash + - session.cashTotal + - session.cashInTotal - - session.cashOutTotal; - - const difference = data.closingCashCounted - expectedCash; - - // Update session - session.status = SessionStatus.CLOSED; - session.closedAt = new Date(); - session.closedBy = data.closedBy; - session.closingCashCounted = data.closingCashCounted; - session.closingCashExpected = expectedCash; - session.closingDifference = difference; - session.closingNotes = data.closingNotes; - session.cashCountDetail = data.cashCountDetail; - - const savedSession = await queryRunner.manager.save(session); - - // Update register status - await queryRunner.manager.update(CashRegister, session.registerId, { - status: RegisterStatus.AVAILABLE, - currentSessionId: null, - currentUserId: null, - }); - - await queryRunner.commitTransaction(); - - return { success: true, data: savedSession }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CLOSE_SESSION_FAILED', - message: error.message || 'Failed to close session', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Get active session for a user - */ - async getActiveSession(tenantId: string, userId: string): Promise { - return this.repository.findOne({ - where: { - tenantId, - userId, - status: SessionStatus.OPEN, - }, - }); - } - - /** - * Get active session for a register - */ - async getRegisterSession(tenantId: string, registerId: string): Promise { - return this.repository.findOne({ - where: { - tenantId, - registerId, - status: SessionStatus.OPEN, - }, - }); - } - - /** - * Get session with orders - */ - async getSessionWithOrders(tenantId: string, sessionId: string): Promise { - return this.repository.findOne({ - where: { id: sessionId, tenantId }, - relations: ['orders'], - }); - } - - /** - * Update session totals after an order - */ - async updateSessionTotals( - sessionId: string, - orderData: { - total: number; - discounts: number; - cashAmount: number; - cardAmount: number; - transferAmount: number; - otherAmount: number; - itemsCount: number; - isRefund: boolean; - } - ): Promise { - const session = await this.repository.findOne({ where: { id: sessionId } }); - if (!session) return; - - if (orderData.isRefund) { - session.totalRefunds += orderData.total; - } else { - session.totalSales += orderData.total; - session.ordersCount += 1; - } - - session.totalDiscounts += orderData.discounts; - session.cashTotal += orderData.cashAmount; - session.cardTotal += orderData.cardAmount; - session.transferTotal += orderData.transferAmount; - session.otherTotal += orderData.otherAmount; - session.itemsSold += orderData.itemsCount; - - await this.repository.save(session); - } - - /** - * Get sessions for a date range - */ - async getSessionsByDateRange( - tenantId: string, - branchId: string, - startDate: Date, - endDate: Date - ): Promise { - return this.repository - .createQueryBuilder('session') - .where('session.tenantId = :tenantId', { tenantId }) - .andWhere('session.branchId = :branchId', { branchId }) - .andWhere('session.openedAt BETWEEN :startDate AND :endDate', { - startDate, - endDate, - }) - .orderBy('session.openedAt', 'DESC') - .getMany(); - } - - /** - * Get daily session summary - */ - async getDailySummary( - tenantId: string, - branchId: string, - date: Date - ): Promise<{ - sessionsCount: number; - totalSales: number; - totalRefunds: number; - totalDiscounts: number; - netSales: number; - ordersCount: number; - }> { - const startOfDay = new Date(date); - startOfDay.setHours(0, 0, 0, 0); - - const endOfDay = new Date(date); - endOfDay.setHours(23, 59, 59, 999); - - const result = await this.repository - .createQueryBuilder('session') - .select('COUNT(*)', 'sessionsCount') - .addSelect('COALESCE(SUM(session.totalSales), 0)', 'totalSales') - .addSelect('COALESCE(SUM(session.totalRefunds), 0)', 'totalRefunds') - .addSelect('COALESCE(SUM(session.totalDiscounts), 0)', 'totalDiscounts') - .addSelect('COALESCE(SUM(session.ordersCount), 0)', 'ordersCount') - .where('session.tenantId = :tenantId', { tenantId }) - .andWhere('session.branchId = :branchId', { branchId }) - .andWhere('session.openedAt BETWEEN :startDate AND :endDate', { - startDate: startOfDay, - endDate: endOfDay, - }) - .getRawOne(); - - return { - sessionsCount: parseInt(result.sessionsCount) || 0, - totalSales: parseFloat(result.totalSales) || 0, - totalRefunds: parseFloat(result.totalRefunds) || 0, - totalDiscounts: parseFloat(result.totalDiscounts) || 0, - netSales: - (parseFloat(result.totalSales) || 0) - (parseFloat(result.totalRefunds) || 0), - ordersCount: parseInt(result.ordersCount) || 0, - }; - } -} - -// Export singleton instance -export const posSessionService = new POSSessionService(); diff --git a/backend/src/modules/pos/validation/pos.schema.ts b/backend/src/modules/pos/validation/pos.schema.ts deleted file mode 100644 index 8f6d67c..0000000 --- a/backend/src/modules/pos/validation/pos.schema.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { z } from 'zod'; -import { - uuidSchema, - moneySchema, - quantitySchema, - percentSchema, - paginationSchema, - rfcSchema, - emailSchema, -} from '../../../shared/validation/common.schema'; - -// Enums -export const paymentMethodEnum = z.enum([ - 'cash', - 'debit_card', - 'credit_card', - 'transfer', - 'check', - 'loyalty_points', - 'voucher', - 'other', -]); - -export const orderStatusEnum = z.enum([ - 'draft', - 'confirmed', - 'paid', - 'partially_paid', - 'voided', - 'refunded', -]); - -// ==================== SESSION SCHEMAS ==================== - -// Open session schema -export const openSessionSchema = z.object({ - registerId: uuidSchema, - openingCash: moneySchema.default(0), - openingNotes: z.string().max(500).optional(), -}); - -// Close session schema -export const closeSessionSchema = z.object({ - closingCashCounted: moneySchema, - closingNotes: z.string().max(500).optional(), - cashCountDetail: z.object({ - bills: z.array(z.object({ - denomination: z.number(), - count: z.number().int().min(0), - total: z.number(), - })).optional(), - coins: z.array(z.object({ - denomination: z.number(), - count: z.number().int().min(0), - total: z.number(), - })).optional(), - total: z.number(), - }).optional(), -}); - -// List sessions query schema -export const listSessionsQuerySchema = paginationSchema.extend({ - status: z.enum(['open', 'closing', 'closed', 'reconciled']).optional(), - userId: uuidSchema.optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), -}); - -// ==================== ORDER SCHEMAS ==================== - -// Order line schema -export const orderLineSchema = z.object({ - productId: uuidSchema, - productCode: z.string().min(1).max(50), - productName: z.string().min(1).max(200), - productBarcode: z.string().max(50).optional(), - variantId: uuidSchema.optional(), - variantName: z.string().max(200).optional(), - quantity: quantitySchema.min(0.0001), - unitPrice: moneySchema, - originalPrice: moneySchema, - discountPercent: percentSchema.optional(), - discountAmount: moneySchema.optional(), - taxRate: z.coerce.number().min(0).max(1).default(0.16), - taxIncluded: z.boolean().default(true), - notes: z.string().max(500).optional(), -}); - -// Create order schema -export const createOrderSchema = z.object({ - sessionId: uuidSchema, - registerId: uuidSchema, - customerId: uuidSchema.optional(), - customerName: z.string().max(200).optional(), - customerRfc: rfcSchema.optional(), - salespersonId: uuidSchema.optional(), - lines: z.array(orderLineSchema).min(1, 'At least one line is required'), - discountPercent: percentSchema.optional(), - discountAmount: moneySchema.optional(), - discountReason: z.string().max(255).optional(), - couponId: uuidSchema.optional(), - couponCode: z.string().max(50).optional(), - requiresInvoice: z.boolean().default(false), - notes: z.string().max(1000).optional(), -}); - -// Add payment schema -export const addPaymentSchema = z.object({ - method: paymentMethodEnum, - amount: moneySchema.positive('Amount must be positive'), - amountReceived: moneySchema.optional(), - cardType: z.string().max(20).optional(), - cardLastFour: z.string().length(4).optional(), - cardHolder: z.string().max(100).optional(), - authorizationCode: z.string().max(50).optional(), - transactionReference: z.string().max(100).optional(), - terminalId: z.string().max(50).optional(), - bankName: z.string().max(100).optional(), - transferReference: z.string().max(100).optional(), - checkNumber: z.string().max(50).optional(), - checkBank: z.string().max(100).optional(), - voucherCode: z.string().max(50).optional(), - loyaltyPointsUsed: z.number().int().min(0).optional(), - tipAmount: moneySchema.optional(), - notes: z.string().max(500).optional(), -}); - -// Void order schema -export const voidOrderSchema = z.object({ - reason: z.string().min(1, 'Reason is required').max(255), -}); - -// List orders query schema -export const listOrdersQuerySchema = paginationSchema.extend({ - sessionId: uuidSchema.optional(), - status: orderStatusEnum.optional(), - customerId: uuidSchema.optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - search: z.string().optional(), -}); - -// ==================== RECEIPT SCHEMAS ==================== - -// Send receipt schema -export const sendReceiptSchema = z.object({ - email: emailSchema, -}); - -// ==================== REFUND SCHEMAS ==================== - -// Refund line schema -export const refundLineSchema = z.object({ - originalLineId: uuidSchema, - quantity: quantitySchema.positive('Quantity must be positive'), - reason: z.string().min(1).max(255), -}); - -// Create refund schema -export const createRefundSchema = z.object({ - originalOrderId: uuidSchema, - sessionId: uuidSchema, - registerId: uuidSchema, - lines: z.array(refundLineSchema).min(1, 'At least one line is required'), - refundReason: z.string().min(1).max(500), -}); - -// Process refund payment schema -export const processRefundPaymentSchema = z.object({ - method: paymentMethodEnum, - amount: moneySchema.positive('Amount must be positive'), -}); - -// ==================== HOLD/RECALL SCHEMAS ==================== - -// Hold order schema -export const holdOrderSchema = z.object({ - holdName: z.string().max(100).optional(), -}); - -// Recall order schema -export const recallOrderSchema = z.object({ - sessionId: uuidSchema, - registerId: uuidSchema, -}); - -// ==================== COUPON SCHEMA ==================== - -// Apply coupon schema -export const applyCouponSchema = z.object({ - couponCode: z.string().min(1).max(50), -}); - -// Types -export type OpenSessionInput = z.infer; -export type CloseSessionInput = z.infer; -export type CreateOrderInput = z.infer; -export type AddPaymentInput = z.infer; -export type VoidOrderInput = z.infer; -export type OrderLineInput = z.infer; -export type RefundLineInput = z.infer; -export type CreateRefundInput = z.infer; -export type ProcessRefundPaymentInput = z.infer; -export type HoldOrderInput = z.infer; -export type RecallOrderInput = z.infer; -export type ApplyCouponInput = z.infer; diff --git a/backend/src/modules/pricing/controllers/index.ts b/backend/src/modules/pricing/controllers/index.ts deleted file mode 100644 index 0be2cea..0000000 --- a/backend/src/modules/pricing/controllers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './pricing.controller'; diff --git a/backend/src/modules/pricing/controllers/pricing.controller.ts b/backend/src/modules/pricing/controllers/pricing.controller.ts deleted file mode 100644 index e3cf6a0..0000000 --- a/backend/src/modules/pricing/controllers/pricing.controller.ts +++ /dev/null @@ -1,985 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { PriceEngineService } from '../services/price-engine.service'; -import { PromotionService } from '../services/promotion.service'; -import { CouponService } from '../services/coupon.service'; -import { - CreatePromotionInput, - UpdatePromotionInput, - CreateCouponInput, - UpdateCouponInput, - CalculatePricesInput, - RedeemCouponInput, - ListPromotionsQuery, - ListCouponsQuery, - PromotionProductInput, - GenerateBulkCouponsInput, -} from '../validation/pricing.schema'; -import { AppDataSource } from '../../../config/database'; -import { Promotion } from '../entities/promotion.entity'; -import { Coupon } from '../entities/coupon.entity'; - -const priceEngineService = new PriceEngineService(AppDataSource); -const promotionService = new PromotionService( - AppDataSource, - AppDataSource.getRepository(Promotion) -); -const couponService = new CouponService( - AppDataSource, - AppDataSource.getRepository(Coupon) -); - -// ==================== PRICE ENGINE ==================== - -/** - * Calculate prices for cart/order items - * POST /api/pricing/calculate - */ -export const calculatePrices = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const input = req.body as CalculatePricesInput; - - const context = { - tenantId, - branchId: input.branchId, - customerId: input.customerId, - customerLevelId: input.customerLevelId, - isNewCustomer: input.isNewCustomer || false, - isLoyaltyMember: input.isLoyaltyMember || false, - isFirstOrder: input.isFirstOrder || false, - channel: input.channel, - }; - - const result = await priceEngineService.calculatePrices( - context, - input.lines, - input.couponCode - ); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -/** - * Validate a coupon code - * POST /api/pricing/validate-coupon - */ -export const validateCoupon = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const branchId = req.headers['x-branch-id'] as string; - const { code, orderAmount } = req.body; - - const context = { - tenantId, - branchId, - channel: 'pos' as const, - isNewCustomer: false, - isLoyaltyMember: false, - isFirstOrder: false, - }; - - const result = await priceEngineService.validateCoupon( - context, - code, - orderAmount - ); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get active promotions for a branch - * GET /api/pricing/active-promotions - */ -export const getActivePromotions = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const branchId = req.headers['x-branch-id'] as string; - const channel = (req.query.channel as 'pos' | 'ecommerce' | 'mobile') || 'pos'; - - const promotions = await priceEngineService.getActivePromotions( - tenantId, - branchId, - channel - ); - - res.json({ - success: true, - data: promotions, - meta: { - count: promotions.length, - }, - }); - } catch (error) { - next(error); - } -}; - -// ==================== PROMOTIONS ==================== - -/** - * List promotions with filters - * GET /api/pricing/promotions - */ -export const listPromotions = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const query = req.query as unknown as ListPromotionsQuery; - - const result = await promotionService.listPromotions(tenantId, query); - - res.json({ - success: true, - data: result.data, - meta: result.meta, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get promotion by ID - * GET /api/pricing/promotions/:id - */ -export const getPromotion = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { id } = req.params; - - const result = await promotionService.getById(tenantId, id, { - relations: ['products'], - }); - - if (!result.success) { - res.status(404).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Create a new promotion - * POST /api/pricing/promotions - */ -export const createPromotion = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const input = req.body as CreatePromotionInput; - - const result = await promotionService.createPromotion(tenantId, input, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.status(201).json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Update a promotion - * PUT /api/pricing/promotions/:id - */ -export const updatePromotion = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - const input = req.body as UpdatePromotionInput; - - const result = await promotionService.updatePromotion(tenantId, id, input, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Activate a promotion - * POST /api/pricing/promotions/:id/activate - */ -export const activatePromotion = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - - const result = await promotionService.activatePromotion(tenantId, id, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Pause a promotion - * POST /api/pricing/promotions/:id/pause - */ -export const pausePromotion = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - - const result = await promotionService.pausePromotion(tenantId, id, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * End a promotion - * POST /api/pricing/promotions/:id/end - */ -export const endPromotion = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - - const result = await promotionService.endPromotion(tenantId, id, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Cancel a promotion - * POST /api/pricing/promotions/:id/cancel - */ -export const cancelPromotion = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - - const result = await promotionService.cancelPromotion(tenantId, id, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Add products to a promotion - * POST /api/pricing/promotions/:id/products - */ -export const addPromotionProducts = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - const { products } = req.body as { products: PromotionProductInput[] }; - - const result = await promotionService.addProducts(tenantId, id, products, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.status(201).json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Remove product from a promotion - * DELETE /api/pricing/promotions/:id/products/:productId - */ -export const removePromotionProduct = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id, productId } = req.params; - - const result = await promotionService.removeProduct(tenantId, id, productId, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - message: 'Product removed from promotion', - }); - } catch (error) { - next(error); - } -}; - -/** - * Delete a promotion - * DELETE /api/pricing/promotions/:id - */ -export const deletePromotion = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - - const result = await promotionService.softDelete(tenantId, id, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - message: 'Promotion deleted', - }); - } catch (error) { - next(error); - } -}; - -// ==================== COUPONS ==================== - -/** - * List coupons with filters - * GET /api/pricing/coupons - */ -export const listCoupons = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const query = req.query as unknown as ListCouponsQuery; - - const result = await couponService.listCoupons(tenantId, query); - - res.json({ - success: true, - data: result.data, - meta: result.meta, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get coupon by ID - * GET /api/pricing/coupons/:id - */ -export const getCoupon = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { id } = req.params; - - const result = await couponService.getById(tenantId, id, { - relations: ['redemptions'], - }); - - if (!result.success) { - res.status(404).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get coupon by code - * GET /api/pricing/coupons/code/:code - */ -export const getCouponByCode = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { code } = req.params; - - const result = await couponService.getByCode(tenantId, code); - - if (!result.success) { - res.status(404).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Create a new coupon - * POST /api/pricing/coupons - */ -export const createCoupon = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const input = req.body as CreateCouponInput; - - const result = await couponService.createCoupon(tenantId, input, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.status(201).json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Generate bulk coupons - * POST /api/pricing/coupons/bulk - */ -export const generateBulkCoupons = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { quantity, prefix, baseInput } = req.body as GenerateBulkCouponsInput; - - const result = await couponService.generateBulkCoupons( - tenantId, - baseInput, - quantity, - prefix, - userId - ); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.status(201).json({ - success: true, - data: result.data, - meta: { - count: result.data?.length || 0, - }, - }); - } catch (error) { - next(error); - } -}; - -/** - * Update a coupon - * PUT /api/pricing/coupons/:id - */ -export const updateCoupon = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - const input = req.body as UpdateCouponInput; - - const result = await couponService.updateCoupon(tenantId, id, input, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Activate a coupon - * POST /api/pricing/coupons/:id/activate - */ -export const activateCoupon = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - - const result = await couponService.activateCoupon(tenantId, id, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Deactivate a coupon - * POST /api/pricing/coupons/:id/deactivate - */ -export const deactivateCoupon = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - - const result = await couponService.deactivateCoupon(tenantId, id, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Redeem a coupon - * POST /api/pricing/coupons/:id/redeem - */ -export const redeemCoupon = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - const input = req.body as RedeemCouponInput; - - // Get coupon code from ID - const couponResult = await couponService.getById(tenantId, id); - if (!couponResult.success || !couponResult.data) { - res.status(404).json({ - success: false, - error: 'Coupon not found', - }); - return; - } - - const result = await couponService.redeemCoupon( - tenantId, - couponResult.data.code, - input, - userId - ); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.status(201).json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Redeem coupon by code - * POST /api/pricing/coupons/redeem - */ -export const redeemCouponByCode = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { code, ...input } = req.body as RedeemCouponInput & { code: string }; - - const result = await couponService.redeemCoupon(tenantId, code, input, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.status(201).json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Reverse a coupon redemption - * POST /api/pricing/coupons/redemptions/:redemptionId/reverse - */ -export const reverseRedemption = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { redemptionId } = req.params; - const { reason } = req.body as { reason: string }; - - const result = await couponService.reverseRedemption( - tenantId, - redemptionId, - reason, - userId - ); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get coupon redemption history - * GET /api/pricing/coupons/:id/redemptions - */ -export const getCouponRedemptions = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { id } = req.params; - const { page = 1, limit = 20 } = req.query; - - const result = await couponService.getRedemptionHistory( - tenantId, - id, - Number(page), - Number(limit) - ); - - res.json({ - success: true, - data: result.data, - meta: result.meta, - }); - } catch (error) { - next(error); - } -}; - -/** - * Delete a coupon - * DELETE /api/pricing/coupons/:id - */ -export const deleteCoupon = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const userId = req.user?.id as string; - const { id } = req.params; - - const result = await couponService.softDelete(tenantId, id, userId); - - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - message: 'Coupon deleted', - }); - } catch (error) { - next(error); - } -}; - -/** - * Get coupon statistics - * GET /api/pricing/coupons/:id/stats - */ -export const getCouponStats = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - try { - const tenantId = req.headers['x-tenant-id'] as string; - const { id } = req.params; - - const result = await couponService.getCouponStats(tenantId, id); - - if (!result.success) { - res.status(404).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - data: result.data, - }); - } catch (error) { - next(error); - } -}; diff --git a/backend/src/modules/pricing/entities/coupon-redemption.entity.ts b/backend/src/modules/pricing/entities/coupon-redemption.entity.ts deleted file mode 100644 index 9879be1..0000000 --- a/backend/src/modules/pricing/entities/coupon-redemption.entity.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { Coupon } from './coupon.entity'; - -export enum RedemptionStatus { - APPLIED = 'applied', - COMPLETED = 'completed', - REVERSED = 'reversed', - CANCELLED = 'cancelled', -} - -@Entity('coupon_redemptions', { schema: 'retail' }) -@Index(['tenantId', 'couponId', 'createdAt']) -@Index(['tenantId', 'customerId']) -@Index(['tenantId', 'orderId']) -export class CouponRedemption { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'coupon_id', type: 'uuid' }) - couponId: string; - - @Column({ name: 'coupon_code', length: 50 }) - couponCode: string; - - @Column({ name: 'order_id', type: 'uuid' }) - orderId: string; - - @Column({ name: 'order_number', length: 30 }) - orderNumber: string; - - @Column({ name: 'customer_id', type: 'uuid', nullable: true }) - customerId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - @Column({ - type: 'enum', - enum: RedemptionStatus, - default: RedemptionStatus.APPLIED, - }) - status: RedemptionStatus; - - // Amounts - @Column({ name: 'order_amount', type: 'decimal', precision: 15, scale: 2 }) - orderAmount: number; - - @Column({ name: 'discount_applied', type: 'decimal', precision: 15, scale: 2 }) - discountApplied: number; - - // Discount breakdown - @Column({ name: 'discount_type', length: 30 }) - discountType: string; - - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) - discountPercent: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - discountAmount: number; - - // Free product info - @Column({ name: 'free_product_id', type: 'uuid', nullable: true }) - freeProductId: string; - - @Column({ name: 'free_product_quantity', type: 'int', nullable: true }) - freeProductQuantity: number; - - @Column({ name: 'free_product_value', type: 'decimal', precision: 15, scale: 2, nullable: true }) - freeProductValue: number; - - // Channel - @Column({ length: 20, default: 'pos' }) - channel: 'pos' | 'ecommerce' | 'mobile'; - - // Reversal info - @Column({ name: 'reversed_at', type: 'timestamp with time zone', nullable: true }) - reversedAt: Date; - - @Column({ name: 'reversed_by', type: 'uuid', nullable: true }) - reversedBy: string; - - @Column({ name: 'reversal_reason', length: 255, nullable: true }) - reversalReason: string; - - // User who applied the coupon - @Column({ name: 'applied_by', type: 'uuid' }) - appliedBy: string; - - // IP address (for e-commerce fraud prevention) - @Column({ name: 'ip_address', length: 45, nullable: true }) - ipAddress: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - // Relations - @ManyToOne(() => Coupon, (coupon) => coupon.redemptions) - @JoinColumn({ name: 'coupon_id' }) - coupon: Coupon; -} diff --git a/backend/src/modules/pricing/entities/coupon.entity.ts b/backend/src/modules/pricing/entities/coupon.entity.ts deleted file mode 100644 index c35e698..0000000 --- a/backend/src/modules/pricing/entities/coupon.entity.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { CouponRedemption } from './coupon-redemption.entity'; - -export enum CouponStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', - EXPIRED = 'expired', - DEPLETED = 'depleted', -} - -export enum CouponType { - PERCENTAGE = 'percentage', - FIXED_AMOUNT = 'fixed_amount', - FREE_SHIPPING = 'free_shipping', - FREE_PRODUCT = 'free_product', -} - -export enum CouponScope { - ORDER = 'order', - PRODUCT = 'product', - CATEGORY = 'category', - SHIPPING = 'shipping', -} - -@Entity('coupons', { schema: 'retail' }) -@Index(['tenantId', 'code'], { unique: true }) -@Index(['tenantId', 'status', 'validFrom', 'validUntil']) -@Index(['tenantId', 'campaignId']) -export class Coupon { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ length: 50, unique: true }) - code: string; - - @Column({ length: 100 }) - name: string; - - @Column({ type: 'text', nullable: true }) - description: string; - - @Column({ - type: 'enum', - enum: CouponType, - }) - type: CouponType; - - @Column({ - type: 'enum', - enum: CouponScope, - default: CouponScope.ORDER, - }) - scope: CouponScope; - - @Column({ - type: 'enum', - enum: CouponStatus, - default: CouponStatus.ACTIVE, - }) - status: CouponStatus; - - // Discount value - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) - discountPercent: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - discountAmount: number; - - @Column({ name: 'max_discount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - maxDiscount: number; - - // Free product - @Column({ name: 'free_product_id', type: 'uuid', nullable: true }) - freeProductId: string; - - @Column({ name: 'free_product_quantity', type: 'int', default: 1 }) - freeProductQuantity: number; - - // Validity - @Column({ name: 'valid_from', type: 'timestamp with time zone' }) - validFrom: Date; - - @Column({ name: 'valid_until', type: 'timestamp with time zone', nullable: true }) - validUntil: Date; - - // Usage limits - @Column({ name: 'max_uses', type: 'int', nullable: true }) - maxUses: number; - - @Column({ name: 'max_uses_per_customer', type: 'int', default: 1 }) - maxUsesPerCustomer: number; - - @Column({ name: 'current_uses', type: 'int', default: 0 }) - currentUses: number; - - // Minimum requirements - @Column({ name: 'min_order_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - minOrderAmount: number; - - @Column({ name: 'min_items', type: 'int', nullable: true }) - minItems: number; - - // Scope restrictions - @Column({ name: 'applies_to_categories', type: 'jsonb', nullable: true }) - appliesToCategories: string[]; - - @Column({ name: 'applies_to_products', type: 'jsonb', nullable: true }) - appliesToProducts: string[]; - - @Column({ name: 'excluded_products', type: 'jsonb', nullable: true }) - excludedProducts: string[]; - - @Column({ name: 'included_branches', type: 'jsonb', nullable: true }) - includedBranches: string[]; - - // Customer restrictions - @Column({ name: 'customer_specific', type: 'boolean', default: false }) - customerSpecific: boolean; - - @Column({ name: 'allowed_customers', type: 'jsonb', nullable: true }) - allowedCustomers: string[]; - - @Column({ name: 'customer_levels', type: 'jsonb', nullable: true }) - customerLevels: string[]; - - @Column({ name: 'new_customers_only', type: 'boolean', default: false }) - newCustomersOnly: boolean; - - @Column({ name: 'first_order_only', type: 'boolean', default: false }) - firstOrderOnly: boolean; - - // Campaign - @Column({ name: 'campaign_id', type: 'uuid', nullable: true }) - campaignId: string; - - @Column({ name: 'campaign_name', length: 100, nullable: true }) - campaignName: string; - - // Stackability - @Column({ name: 'stackable_with_promotions', type: 'boolean', default: false }) - stackableWithPromotions: boolean; - - @Column({ name: 'stackable_with_coupons', type: 'boolean', default: false }) - stackableWithCoupons: boolean; - - // Channels - @Column({ name: 'pos_enabled', type: 'boolean', default: true }) - posEnabled: boolean; - - @Column({ name: 'ecommerce_enabled', type: 'boolean', default: true }) - ecommerceEnabled: boolean; - - // Distribution - @Column({ name: 'is_single_use', type: 'boolean', default: false }) - isSingleUse: boolean; - - @Column({ name: 'auto_apply', type: 'boolean', default: false }) - autoApply: boolean; - - @Column({ name: 'public_visible', type: 'boolean', default: false }) - publicVisible: boolean; - - // Terms - @Column({ name: 'terms_and_conditions', type: 'text', nullable: true }) - termsAndConditions: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; - - // Relations - @OneToMany(() => CouponRedemption, (redemption) => redemption.coupon) - redemptions: CouponRedemption[]; -} diff --git a/backend/src/modules/pricing/entities/index.ts b/backend/src/modules/pricing/entities/index.ts deleted file mode 100644 index 3af0211..0000000 --- a/backend/src/modules/pricing/entities/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './promotion.entity'; -export * from './promotion-product.entity'; -export * from './coupon.entity'; -export * from './coupon-redemption.entity'; diff --git a/backend/src/modules/pricing/entities/promotion-product.entity.ts b/backend/src/modules/pricing/entities/promotion-product.entity.ts deleted file mode 100644 index 2ca1ef4..0000000 --- a/backend/src/modules/pricing/entities/promotion-product.entity.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { Promotion } from './promotion.entity'; - -export enum ProductRole { - TRIGGER = 'trigger', // Must be purchased to activate promotion - TARGET = 'target', // Receives the discount/benefit - BOTH = 'both', // Triggers and receives discount - GIFT = 'gift', // Free gift product -} - -@Entity('promotion_products', { schema: 'retail' }) -@Index(['tenantId', 'promotionId']) -@Index(['tenantId', 'productId']) -@Index(['tenantId', 'promotionId', 'productId'], { unique: true }) -export class PromotionProduct { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'promotion_id', type: 'uuid' }) - promotionId: string; - - // Product (from erp-core) - @Column({ name: 'product_id', type: 'uuid' }) - productId: string; - - @Column({ name: 'product_code', length: 50 }) - productCode: string; - - @Column({ name: 'product_name', length: 200 }) - productName: string; - - // Variant (optional) - @Column({ name: 'variant_id', type: 'uuid', nullable: true }) - variantId: string; - - @Column({ - type: 'enum', - enum: ProductRole, - default: ProductRole.BOTH, - }) - role: ProductRole; - - // Specific discount for this product (overrides promotion default) - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) - discountPercent: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - discountAmount: number; - - @Column({ name: 'fixed_price', type: 'decimal', precision: 15, scale: 2, nullable: true }) - fixedPrice: number; - - // Quantity limits for this product - @Column({ name: 'min_quantity', type: 'int', default: 1 }) - minQuantity: number; - - @Column({ name: 'max_quantity', type: 'int', nullable: true }) - maxQuantity: number; - - // For bundles - quantity included in bundle - @Column({ name: 'bundle_quantity', type: 'int', default: 1 }) - bundleQuantity: number; - - // For buy X get Y - quantity to get free/discounted - @Column({ name: 'get_quantity', type: 'int', nullable: true }) - getQuantity: number; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - // Relations - @ManyToOne(() => Promotion, (promotion) => promotion.products) - @JoinColumn({ name: 'promotion_id' }) - promotion: Promotion; -} diff --git a/backend/src/modules/pricing/entities/promotion.entity.ts b/backend/src/modules/pricing/entities/promotion.entity.ts deleted file mode 100644 index 6a66132..0000000 --- a/backend/src/modules/pricing/entities/promotion.entity.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { PromotionProduct } from './promotion-product.entity'; - -export enum PromotionStatus { - DRAFT = 'draft', - SCHEDULED = 'scheduled', - ACTIVE = 'active', - PAUSED = 'paused', - ENDED = 'ended', - CANCELLED = 'cancelled', -} - -export enum PromotionType { - PERCENTAGE_DISCOUNT = 'percentage_discount', - FIXED_DISCOUNT = 'fixed_discount', - BUY_X_GET_Y = 'buy_x_get_y', - BUNDLE = 'bundle', - FLASH_SALE = 'flash_sale', - QUANTITY_DISCOUNT = 'quantity_discount', - FREE_SHIPPING = 'free_shipping', - GIFT_WITH_PURCHASE = 'gift_with_purchase', -} - -export enum DiscountApplication { - ORDER = 'order', - LINE = 'line', - SHIPPING = 'shipping', -} - -@Entity('promotions', { schema: 'retail' }) -@Index(['tenantId', 'status', 'startDate', 'endDate']) -@Index(['tenantId', 'code'], { unique: true }) -@Index(['tenantId', 'type']) -export class Promotion { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ length: 30, unique: true }) - code: string; - - @Column({ length: 100 }) - name: string; - - @Column({ type: 'text', nullable: true }) - description: string; - - @Column({ - type: 'enum', - enum: PromotionType, - }) - type: PromotionType; - - @Column({ - type: 'enum', - enum: PromotionStatus, - default: PromotionStatus.DRAFT, - }) - status: PromotionStatus; - - @Column({ - name: 'discount_application', - type: 'enum', - enum: DiscountApplication, - default: DiscountApplication.LINE, - }) - discountApplication: DiscountApplication; - - // Discount values - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) - discountPercent: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - discountAmount: number; - - @Column({ name: 'max_discount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - maxDiscount: number; - - // Buy X Get Y config - @Column({ name: 'buy_quantity', type: 'int', nullable: true }) - buyQuantity: number; - - @Column({ name: 'get_quantity', type: 'int', nullable: true }) - getQuantity: number; - - @Column({ name: 'get_discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) - getDiscountPercent: number; - - // Quantity discount config - @Column({ name: 'quantity_tiers', type: 'jsonb', nullable: true }) - quantityTiers: { - minQuantity: number; - discountPercent: number; - }[]; - - // Bundle config - @Column({ name: 'bundle_price', type: 'decimal', precision: 15, scale: 2, nullable: true }) - bundlePrice: number; - - // Validity - @Column({ name: 'start_date', type: 'timestamp with time zone' }) - startDate: Date; - - @Column({ name: 'end_date', type: 'timestamp with time zone', nullable: true }) - endDate: Date; - - // Time restrictions - @Column({ name: 'valid_days', type: 'jsonb', nullable: true }) - validDays: string[]; // ['monday', 'tuesday', ...] - - @Column({ name: 'valid_hours_start', type: 'time', nullable: true }) - validHoursStart: string; - - @Column({ name: 'valid_hours_end', type: 'time', nullable: true }) - validHoursEnd: string; - - // Usage limits - @Column({ name: 'max_uses_total', type: 'int', nullable: true }) - maxUsesTotal: number; - - @Column({ name: 'max_uses_per_customer', type: 'int', nullable: true }) - maxUsesPerCustomer: number; - - @Column({ name: 'current_uses', type: 'int', default: 0 }) - currentUses: number; - - // Minimum requirements - @Column({ name: 'min_order_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) - minOrderAmount: number; - - @Column({ name: 'min_quantity', type: 'int', nullable: true }) - minQuantity: number; - - // Scope - @Column({ name: 'applies_to_all_products', type: 'boolean', default: false }) - appliesToAllProducts: boolean; - - @Column({ name: 'included_categories', type: 'jsonb', nullable: true }) - includedCategories: string[]; - - @Column({ name: 'excluded_categories', type: 'jsonb', nullable: true }) - excludedCategories: string[]; - - @Column({ name: 'excluded_products', type: 'jsonb', nullable: true }) - excludedProducts: string[]; - - @Column({ name: 'included_branches', type: 'jsonb', nullable: true }) - includedBranches: string[]; - - // Customer restrictions - @Column({ name: 'customer_levels', type: 'jsonb', nullable: true }) - customerLevels: string[]; // membership level IDs - - @Column({ name: 'new_customers_only', type: 'boolean', default: false }) - newCustomersOnly: boolean; - - @Column({ name: 'loyalty_members_only', type: 'boolean', default: false }) - loyaltyMembersOnly: boolean; - - // Stackability - @Column({ name: 'stackable', type: 'boolean', default: false }) - stackable: boolean; - - @Column({ name: 'stackable_with', type: 'jsonb', nullable: true }) - stackableWith: string[]; // promotion IDs that can stack - - @Column({ type: 'int', default: 0 }) - priority: number; // higher = applied first - - // Display - @Column({ name: 'display_name', length: 100, nullable: true }) - displayName: string; - - @Column({ name: 'badge_text', length: 30, nullable: true }) - badgeText: string; - - @Column({ name: 'badge_color', length: 7, default: '#ff0000' }) - badgeColor: string; - - @Column({ name: 'image_url', length: 255, nullable: true }) - imageUrl: string; - - // Channels - @Column({ name: 'pos_enabled', type: 'boolean', default: true }) - posEnabled: boolean; - - @Column({ name: 'ecommerce_enabled', type: 'boolean', default: true }) - ecommerceEnabled: boolean; - - // Terms - @Column({ name: 'terms_and_conditions', type: 'text', nullable: true }) - termsAndConditions: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy: string; - - // Relations - @OneToMany(() => PromotionProduct, (pp) => pp.promotion) - products: PromotionProduct[]; -} diff --git a/backend/src/modules/pricing/index.ts b/backend/src/modules/pricing/index.ts deleted file mode 100644 index fd9152d..0000000 --- a/backend/src/modules/pricing/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Services -export * from './services'; - -// Controllers -export * from './controllers'; - -// Routes -export * from './routes'; - -// Validation -export * from './validation'; - -// Entities -export * from './entities'; diff --git a/backend/src/modules/pricing/routes/index.ts b/backend/src/modules/pricing/routes/index.ts deleted file mode 100644 index 5aa87c7..0000000 --- a/backend/src/modules/pricing/routes/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as pricingRoutes } from './pricing.routes'; diff --git a/backend/src/modules/pricing/routes/pricing.routes.ts b/backend/src/modules/pricing/routes/pricing.routes.ts deleted file mode 100644 index 3264204..0000000 --- a/backend/src/modules/pricing/routes/pricing.routes.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { Router } from 'express'; -import { authMiddleware } from '../../auth/middleware/auth.middleware'; -import { branchMiddleware } from '../../branches/middleware/branch.middleware'; -import { validateRequest } from '../../../shared/middleware/validation.middleware'; -import { requirePermissions } from '../../../shared/middleware/permissions.middleware'; -import { - createPromotionSchema, - updatePromotionSchema, - addPromotionProductsSchema, - listPromotionsQuerySchema, - createCouponSchema, - updateCouponSchema, - generateBulkCouponsSchema, - redeemCouponSchema, - validateCouponSchema, - reverseRedemptionSchema, - listCouponsQuerySchema, - calculatePricesSchema, -} from '../validation/pricing.schema'; -import * as pricingController from '../controllers/pricing.controller'; - -const router = Router(); - -// All routes require authentication -router.use(authMiddleware); - -// ==================== PRICE ENGINE ROUTES ==================== - -/** - * Calculate prices for cart/order items - * Requires branch context - */ -router.post( - '/calculate', - branchMiddleware, - validateRequest(calculatePricesSchema), - requirePermissions('pricing:calculate'), - pricingController.calculatePrices -); - -/** - * Validate a coupon code - * Requires branch context - */ -router.post( - '/validate-coupon', - branchMiddleware, - validateRequest(validateCouponSchema), - requirePermissions('pricing:validate'), - pricingController.validateCoupon -); - -/** - * Get active promotions for current branch - * Requires branch context - */ -router.get( - '/active-promotions', - branchMiddleware, - requirePermissions('pricing:view'), - pricingController.getActivePromotions -); - -// ==================== PROMOTION ROUTES ==================== - -/** - * List promotions with filters - */ -router.get( - '/promotions', - validateRequest(listPromotionsQuerySchema, 'query'), - requirePermissions('promotions:view'), - pricingController.listPromotions -); - -/** - * Get promotion by ID - */ -router.get( - '/promotions/:id', - requirePermissions('promotions:view'), - pricingController.getPromotion -); - -/** - * Create a new promotion - */ -router.post( - '/promotions', - validateRequest(createPromotionSchema), - requirePermissions('promotions:create'), - pricingController.createPromotion -); - -/** - * Update a promotion - */ -router.put( - '/promotions/:id', - validateRequest(updatePromotionSchema), - requirePermissions('promotions:update'), - pricingController.updatePromotion -); - -/** - * Activate a promotion - */ -router.post( - '/promotions/:id/activate', - requirePermissions('promotions:activate'), - pricingController.activatePromotion -); - -/** - * Pause a promotion - */ -router.post( - '/promotions/:id/pause', - requirePermissions('promotions:update'), - pricingController.pausePromotion -); - -/** - * End a promotion - */ -router.post( - '/promotions/:id/end', - requirePermissions('promotions:update'), - pricingController.endPromotion -); - -/** - * Cancel a promotion - */ -router.post( - '/promotions/:id/cancel', - requirePermissions('promotions:delete'), - pricingController.cancelPromotion -); - -/** - * Add products to a promotion - */ -router.post( - '/promotions/:id/products', - validateRequest(addPromotionProductsSchema), - requirePermissions('promotions:update'), - pricingController.addPromotionProducts -); - -/** - * Remove product from a promotion - */ -router.delete( - '/promotions/:id/products/:productId', - requirePermissions('promotions:update'), - pricingController.removePromotionProduct -); - -/** - * Delete a promotion (soft delete) - */ -router.delete( - '/promotions/:id', - requirePermissions('promotions:delete'), - pricingController.deletePromotion -); - -// ==================== COUPON ROUTES ==================== - -/** - * List coupons with filters - */ -router.get( - '/coupons', - validateRequest(listCouponsQuerySchema, 'query'), - requirePermissions('coupons:view'), - pricingController.listCoupons -); - -/** - * Get coupon by ID - */ -router.get( - '/coupons/:id', - requirePermissions('coupons:view'), - pricingController.getCoupon -); - -/** - * Get coupon by code - */ -router.get( - '/coupons/code/:code', - requirePermissions('coupons:view'), - pricingController.getCouponByCode -); - -/** - * Create a new coupon - */ -router.post( - '/coupons', - validateRequest(createCouponSchema), - requirePermissions('coupons:create'), - pricingController.createCoupon -); - -/** - * Generate bulk coupons - */ -router.post( - '/coupons/bulk', - validateRequest(generateBulkCouponsSchema), - requirePermissions('coupons:create'), - pricingController.generateBulkCoupons -); - -/** - * Update a coupon - */ -router.put( - '/coupons/:id', - validateRequest(updateCouponSchema), - requirePermissions('coupons:update'), - pricingController.updateCoupon -); - -/** - * Activate a coupon - */ -router.post( - '/coupons/:id/activate', - requirePermissions('coupons:activate'), - pricingController.activateCoupon -); - -/** - * Deactivate a coupon - */ -router.post( - '/coupons/:id/deactivate', - requirePermissions('coupons:update'), - pricingController.deactivateCoupon -); - -/** - * Redeem a coupon by ID - */ -router.post( - '/coupons/:id/redeem', - branchMiddleware, - validateRequest(redeemCouponSchema), - requirePermissions('coupons:redeem'), - pricingController.redeemCoupon -); - -/** - * Redeem coupon by code - */ -router.post( - '/coupons/redeem', - branchMiddleware, - validateRequest(redeemCouponSchema), - requirePermissions('coupons:redeem'), - pricingController.redeemCouponByCode -); - -/** - * Reverse a coupon redemption - */ -router.post( - '/coupons/redemptions/:redemptionId/reverse', - validateRequest(reverseRedemptionSchema), - requirePermissions('coupons:reverse'), - pricingController.reverseRedemption -); - -/** - * Get coupon redemption history - */ -router.get( - '/coupons/:id/redemptions', - requirePermissions('coupons:view'), - pricingController.getCouponRedemptions -); - -/** - * Get coupon statistics - */ -router.get( - '/coupons/:id/stats', - requirePermissions('coupons:view'), - pricingController.getCouponStats -); - -/** - * Delete a coupon (soft delete) - */ -router.delete( - '/coupons/:id', - requirePermissions('coupons:delete'), - pricingController.deleteCoupon -); - -export default router; diff --git a/backend/src/modules/pricing/services/coupon.service.ts b/backend/src/modules/pricing/services/coupon.service.ts deleted file mode 100644 index ef39e3b..0000000 --- a/backend/src/modules/pricing/services/coupon.service.ts +++ /dev/null @@ -1,643 +0,0 @@ -import { Repository, DataSource, Between } from 'typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult, QueryOptions } from '../../../shared/types'; -import { Coupon, CouponStatus, CouponType, CouponScope } from '../entities/coupon.entity'; -import { CouponRedemption, RedemptionStatus } from '../entities/coupon-redemption.entity'; - -export interface CreateCouponInput { - code: string; - name: string; - description?: string; - type: CouponType; - scope?: CouponScope; - discountPercent?: number; - discountAmount?: number; - maxDiscount?: number; - freeProductId?: string; - freeProductQuantity?: number; - validFrom: Date; - validUntil?: Date; - maxUses?: number; - maxUsesPerCustomer?: number; - minOrderAmount?: number; - minItems?: number; - appliesToCategories?: string[]; - appliesToProducts?: string[]; - excludedProducts?: string[]; - includedBranches?: string[]; - customerSpecific?: boolean; - allowedCustomers?: string[]; - customerLevels?: string[]; - newCustomersOnly?: boolean; - firstOrderOnly?: boolean; - campaignId?: string; - campaignName?: string; - stackableWithPromotions?: boolean; - stackableWithCoupons?: boolean; - posEnabled?: boolean; - ecommerceEnabled?: boolean; - isSingleUse?: boolean; - autoApply?: boolean; - publicVisible?: boolean; - termsAndConditions?: string; -} - -export interface RedeemCouponInput { - orderId: string; - orderNumber: string; - customerId?: string; - branchId: string; - orderAmount: number; - discountApplied: number; - channel: 'pos' | 'ecommerce' | 'mobile'; - ipAddress?: string; -} - -export interface CouponQueryOptions extends QueryOptions { - status?: CouponStatus; - type?: CouponType; - campaignId?: string; - branchId?: string; - activeOnly?: boolean; - publicOnly?: boolean; -} - -export class CouponService extends BaseService { - constructor( - repository: Repository, - private readonly redemptionRepository: Repository, - private readonly dataSource: DataSource - ) { - super(repository); - } - - /** - * Generate unique coupon code - */ - async generateCode(tenantId: string, prefix: string = 'CPN'): Promise { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - let code: string; - let exists = true; - - while (exists) { - let random = ''; - for (let i = 0; i < 8; i++) { - random += chars.charAt(Math.floor(Math.random() * chars.length)); - } - code = `${prefix}-${random}`; - - const existing = await this.repository.findOne({ - where: { tenantId, code }, - }); - exists = !!existing; - } - - return code!; - } - - /** - * Create a new coupon - */ - async createCoupon( - tenantId: string, - input: CreateCouponInput, - userId: string - ): Promise> { - // Check for duplicate code - const existing = await this.repository.findOne({ - where: { tenantId, code: input.code.toUpperCase() }, - }); - - if (existing) { - return { - success: false, - error: { code: 'DUPLICATE_CODE', message: 'Coupon code already exists' }, - }; - } - - const coupon = this.repository.create({ - tenantId, - code: input.code.toUpperCase(), - name: input.name, - description: input.description, - type: input.type, - scope: input.scope ?? CouponScope.ORDER, - status: CouponStatus.ACTIVE, - discountPercent: input.discountPercent, - discountAmount: input.discountAmount, - maxDiscount: input.maxDiscount, - freeProductId: input.freeProductId, - freeProductQuantity: input.freeProductQuantity ?? 1, - validFrom: input.validFrom, - validUntil: input.validUntil, - maxUses: input.maxUses, - maxUsesPerCustomer: input.maxUsesPerCustomer ?? 1, - minOrderAmount: input.minOrderAmount, - minItems: input.minItems, - appliesToCategories: input.appliesToCategories, - appliesToProducts: input.appliesToProducts, - excludedProducts: input.excludedProducts, - includedBranches: input.includedBranches, - customerSpecific: input.customerSpecific ?? false, - allowedCustomers: input.allowedCustomers, - customerLevels: input.customerLevels, - newCustomersOnly: input.newCustomersOnly ?? false, - firstOrderOnly: input.firstOrderOnly ?? false, - campaignId: input.campaignId, - campaignName: input.campaignName, - stackableWithPromotions: input.stackableWithPromotions ?? false, - stackableWithCoupons: input.stackableWithCoupons ?? false, - posEnabled: input.posEnabled ?? true, - ecommerceEnabled: input.ecommerceEnabled ?? true, - isSingleUse: input.isSingleUse ?? false, - autoApply: input.autoApply ?? false, - publicVisible: input.publicVisible ?? false, - termsAndConditions: input.termsAndConditions, - createdBy: userId, - }); - - const saved = await this.repository.save(coupon); - return { success: true, data: saved }; - } - - /** - * Generate bulk coupons for a campaign - */ - async generateBulkCoupons( - tenantId: string, - baseInput: Omit, - quantity: number, - prefix: string, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const coupons: Coupon[] = []; - - for (let i = 0; i < quantity; i++) { - const code = await this.generateCode(tenantId, prefix); - - const coupon = queryRunner.manager.create(Coupon, { - tenantId, - code, - ...baseInput, - status: CouponStatus.ACTIVE, - isSingleUse: true, - createdBy: userId, - }); - - const saved = await queryRunner.manager.save(coupon); - coupons.push(saved); - } - - await queryRunner.commitTransaction(); - return { success: true, data: coupons }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'BULK_CREATE_ERROR', - message: error instanceof Error ? error.message : 'Failed to create bulk coupons', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Update coupon - */ - async updateCoupon( - tenantId: string, - id: string, - input: Partial, - userId: string - ): Promise> { - const coupon = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!coupon) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Coupon not found' }, - }; - } - - // Don't allow changing code - delete input.code; - - Object.assign(coupon, input); - const saved = await this.repository.save(coupon); - - return { success: true, data: saved }; - } - - /** - * Deactivate coupon - */ - async deactivateCoupon( - tenantId: string, - id: string, - userId: string - ): Promise> { - const coupon = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!coupon) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Coupon not found' }, - }; - } - - coupon.status = CouponStatus.INACTIVE; - const saved = await this.repository.save(coupon); - - return { success: true, data: saved }; - } - - /** - * Record coupon redemption - */ - async redeemCoupon( - tenantId: string, - couponCode: string, - input: RedeemCouponInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const coupon = await queryRunner.manager.findOne(Coupon, { - where: { tenantId, code: couponCode.toUpperCase() }, - }); - - if (!coupon) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Coupon not found' }, - }; - } - - // Check if already redeemed for this order - const existingRedemption = await queryRunner.manager.findOne(CouponRedemption, { - where: { tenantId, couponId: coupon.id, orderId: input.orderId }, - }); - - if (existingRedemption) { - return { - success: false, - error: { code: 'ALREADY_REDEEMED', message: 'Coupon already redeemed for this order' }, - }; - } - - // Check customer usage limit - if (input.customerId && coupon.maxUsesPerCustomer) { - const customerUses = await queryRunner.manager.count(CouponRedemption, { - where: { - tenantId, - couponId: coupon.id, - customerId: input.customerId, - status: RedemptionStatus.COMPLETED, - }, - }); - - if (customerUses >= coupon.maxUsesPerCustomer) { - return { - success: false, - error: { code: 'MAX_USES_REACHED', message: 'Customer usage limit reached' }, - }; - } - } - - // Create redemption - const redemption = queryRunner.manager.create(CouponRedemption, { - tenantId, - couponId: coupon.id, - couponCode: coupon.code, - orderId: input.orderId, - orderNumber: input.orderNumber, - customerId: input.customerId, - branchId: input.branchId, - status: RedemptionStatus.APPLIED, - orderAmount: input.orderAmount, - discountApplied: input.discountApplied, - discountType: coupon.type, - discountPercent: coupon.discountPercent, - discountAmount: coupon.discountAmount, - freeProductId: coupon.freeProductId, - freeProductQuantity: coupon.freeProductQuantity, - channel: input.channel, - ipAddress: input.ipAddress, - appliedBy: userId, - }); - - const savedRedemption = await queryRunner.manager.save(redemption); - - // Increment coupon usage - coupon.currentUses += 1; - - // Check if depleted - if (coupon.maxUses && coupon.currentUses >= coupon.maxUses) { - coupon.status = CouponStatus.DEPLETED; - } - - // Mark as inactive if single use - if (coupon.isSingleUse) { - coupon.status = CouponStatus.DEPLETED; - } - - await queryRunner.manager.save(coupon); - await queryRunner.commitTransaction(); - - return { success: true, data: savedRedemption }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'REDEEM_ERROR', - message: error instanceof Error ? error.message : 'Failed to redeem coupon', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Complete redemption (after order is finalized) - */ - async completeRedemption( - tenantId: string, - redemptionId: string - ): Promise> { - const redemption = await this.redemptionRepository.findOne({ - where: { id: redemptionId, tenantId }, - }); - - if (!redemption) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Redemption not found' }, - }; - } - - redemption.status = RedemptionStatus.COMPLETED; - const saved = await this.redemptionRepository.save(redemption); - - return { success: true, data: saved }; - } - - /** - * Reverse redemption (e.g., order cancelled) - */ - async reverseRedemption( - tenantId: string, - redemptionId: string, - reason: string, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const redemption = await queryRunner.manager.findOne(CouponRedemption, { - where: { id: redemptionId, tenantId }, - }); - - if (!redemption) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Redemption not found' }, - }; - } - - if (redemption.status === RedemptionStatus.REVERSED) { - return { - success: false, - error: { code: 'ALREADY_REVERSED', message: 'Redemption is already reversed' }, - }; - } - - redemption.status = RedemptionStatus.REVERSED; - redemption.reversedAt = new Date(); - redemption.reversedBy = userId; - redemption.reversalReason = reason; - - await queryRunner.manager.save(redemption); - - // Restore coupon usage - const coupon = await queryRunner.manager.findOne(Coupon, { - where: { id: redemption.couponId, tenantId }, - }); - - if (coupon) { - coupon.currentUses = Math.max(0, coupon.currentUses - 1); - - // Reactivate if was depleted - if (coupon.status === CouponStatus.DEPLETED && !coupon.isSingleUse) { - coupon.status = CouponStatus.ACTIVE; - } - - await queryRunner.manager.save(coupon); - } - - await queryRunner.commitTransaction(); - return { success: true, data: redemption }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'REVERSE_ERROR', - message: error instanceof Error ? error.message : 'Failed to reverse redemption', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Find coupons with filters - */ - async findCoupons( - tenantId: string, - options: CouponQueryOptions - ): Promise<{ data: Coupon[]; total: number }> { - const qb = this.repository.createQueryBuilder('coupon') - .where('coupon.tenantId = :tenantId', { tenantId }); - - if (options.status) { - qb.andWhere('coupon.status = :status', { status: options.status }); - } - - if (options.type) { - qb.andWhere('coupon.type = :type', { type: options.type }); - } - - if (options.campaignId) { - qb.andWhere('coupon.campaignId = :campaignId', { campaignId: options.campaignId }); - } - - if (options.branchId) { - qb.andWhere('(coupon.includedBranches IS NULL OR coupon.includedBranches @> :branchArray)', { - branchArray: JSON.stringify([options.branchId]), - }); - } - - if (options.activeOnly) { - const now = new Date(); - qb.andWhere('coupon.status = :activeStatus', { activeStatus: CouponStatus.ACTIVE }) - .andWhere('coupon.validFrom <= :now', { now }) - .andWhere('(coupon.validUntil IS NULL OR coupon.validUntil >= :now)', { now }); - } - - if (options.publicOnly) { - qb.andWhere('coupon.publicVisible = true'); - } - - const page = options.page ?? 1; - const limit = options.limit ?? 20; - qb.skip((page - 1) * limit).take(limit); - qb.orderBy('coupon.createdAt', 'DESC'); - - const [data, total] = await qb.getManyAndCount(); - return { data, total }; - } - - /** - * Get coupon by code - */ - async getCouponByCode( - tenantId: string, - code: string - ): Promise { - return this.repository.findOne({ - where: { tenantId, code: code.toUpperCase() }, - }); - } - - /** - * Get redemption history for a coupon - */ - async getRedemptionHistory( - tenantId: string, - couponId: string, - options?: { page?: number; limit?: number } - ): Promise<{ data: CouponRedemption[]; total: number }> { - const page = options?.page ?? 1; - const limit = options?.limit ?? 20; - - const [data, total] = await this.redemptionRepository.findAndCount({ - where: { tenantId, couponId }, - order: { createdAt: 'DESC' }, - skip: (page - 1) * limit, - take: limit, - }); - - return { data, total }; - } - - /** - * Get customer redemption history - */ - async getCustomerRedemptions( - tenantId: string, - customerId: string, - options?: { page?: number; limit?: number } - ): Promise<{ data: CouponRedemption[]; total: number }> { - const page = options?.page ?? 1; - const limit = options?.limit ?? 20; - - const [data, total] = await this.redemptionRepository.findAndCount({ - where: { tenantId, customerId }, - relations: ['coupon'], - order: { createdAt: 'DESC' }, - skip: (page - 1) * limit, - take: limit, - }); - - return { data, total }; - } - - /** - * Get coupon statistics - */ - async getCouponStats( - tenantId: string, - couponId: string - ): Promise<{ - totalRedemptions: number; - completedRedemptions: number; - reversedRedemptions: number; - totalDiscountGiven: number; - remainingUses: number | null; - }> { - const coupon = await this.repository.findOne({ - where: { id: couponId, tenantId }, - }); - - if (!coupon) { - return { - totalRedemptions: 0, - completedRedemptions: 0, - reversedRedemptions: 0, - totalDiscountGiven: 0, - remainingUses: null, - }; - } - - const redemptions = await this.redemptionRepository.find({ - where: { tenantId, couponId }, - }); - - const completedRedemptions = redemptions.filter( - r => r.status === RedemptionStatus.COMPLETED - ); - - const totalDiscountGiven = completedRedemptions.reduce( - (sum, r) => sum + Number(r.discountApplied), - 0 - ); - - return { - totalRedemptions: redemptions.length, - completedRedemptions: completedRedemptions.length, - reversedRedemptions: redemptions.filter(r => r.status === RedemptionStatus.REVERSED).length, - totalDiscountGiven, - remainingUses: coupon.maxUses ? coupon.maxUses - coupon.currentUses : null, - }; - } - - /** - * Expire outdated coupons - */ - async expireOutdatedCoupons(tenantId: string): Promise { - const now = new Date(); - - const result = await this.repository.update( - { - tenantId, - status: CouponStatus.ACTIVE, - validUntil: now, - }, - { - status: CouponStatus.EXPIRED, - } - ); - - return result.affected ?? 0; - } -} diff --git a/backend/src/modules/pricing/services/index.ts b/backend/src/modules/pricing/services/index.ts deleted file mode 100644 index f3820b2..0000000 --- a/backend/src/modules/pricing/services/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './price-engine.service'; -export * from './promotion.service'; -export * from './coupon.service'; diff --git a/backend/src/modules/pricing/services/price-engine.service.ts b/backend/src/modules/pricing/services/price-engine.service.ts deleted file mode 100644 index b9b3a18..0000000 --- a/backend/src/modules/pricing/services/price-engine.service.ts +++ /dev/null @@ -1,725 +0,0 @@ -import { Repository, LessThanOrEqual, MoreThanOrEqual, In } from 'typeorm'; -import { - Promotion, - PromotionStatus, - PromotionType, - DiscountApplication, -} from '../entities/promotion.entity'; -import { PromotionProduct, ProductRole } from '../entities/promotion-product.entity'; -import { Coupon, CouponStatus, CouponType, CouponScope } from '../entities/coupon.entity'; - -export interface LineItem { - productId: string; - productCode: string; - productName: string; - variantId?: string; - categoryId?: string; - quantity: number; - unitPrice: number; - originalPrice: number; -} - -export interface PriceContext { - tenantId: string; - branchId: string; - customerId?: string; - customerLevelId?: string; - isNewCustomer?: boolean; - isLoyaltyMember?: boolean; - isFirstOrder?: boolean; - channel: 'pos' | 'ecommerce' | 'mobile'; -} - -export interface AppliedDiscount { - type: 'promotion' | 'coupon'; - id: string; - code: string; - name: string; - discountType: PromotionType | CouponType; - discountPercent?: number; - discountAmount?: number; - appliedToLineIndex?: number; - lineDiscount?: number; - orderDiscount?: number; - freeProductId?: string; - freeProductQuantity?: number; -} - -export interface PricedLine extends LineItem { - lineIndex: number; - subtotal: number; - discountAmount: number; - discountPercent: number; - finalPrice: number; - appliedPromotions: string[]; -} - -export interface PricingResult { - lines: PricedLine[]; - subtotal: number; - totalLineDiscounts: number; - orderDiscount: number; - totalDiscount: number; - total: number; - appliedDiscounts: AppliedDiscount[]; - freeProducts: { - productId: string; - quantity: number; - sourcePromotion?: string; - sourceCoupon?: string; - }[]; - errors: string[]; -} - -export class PriceEngineService { - constructor( - private readonly promotionRepository: Repository, - private readonly promotionProductRepository: Repository, - private readonly couponRepository: Repository - ) {} - - /** - * Get active promotions for a tenant/branch - */ - async getActivePromotions( - tenantId: string, - branchId: string, - channel: 'pos' | 'ecommerce' | 'mobile' - ): Promise { - const now = new Date(); - const today = now.toLocaleDateString('en-US', { weekday: 'lowercase' }); - const currentTime = now.toTimeString().slice(0, 5); - - const qb = this.promotionRepository.createQueryBuilder('p') - .leftJoinAndSelect('p.products', 'products') - .where('p.tenantId = :tenantId', { tenantId }) - .andWhere('p.status = :status', { status: PromotionStatus.ACTIVE }) - .andWhere('p.startDate <= :now', { now }) - .andWhere('(p.endDate IS NULL OR p.endDate >= :now)', { now }); - - // Channel filter - if (channel === 'pos') { - qb.andWhere('p.posEnabled = true'); - } else { - qb.andWhere('p.ecommerceEnabled = true'); - } - - // Branch filter - qb.andWhere('(p.includedBranches IS NULL OR p.includedBranches @> :branchArray)', { - branchArray: JSON.stringify([branchId]), - }); - - qb.orderBy('p.priority', 'DESC'); - - const promotions = await qb.getMany(); - - // Filter by day and time restrictions - return promotions.filter(p => { - // Check day restrictions - if (p.validDays && p.validDays.length > 0) { - if (!p.validDays.includes(today)) return false; - } - - // Check time restrictions - if (p.validHoursStart && p.validHoursEnd) { - if (currentTime < p.validHoursStart || currentTime > p.validHoursEnd) { - return false; - } - } - - // Check usage limits - if (p.maxUsesTotal && p.currentUses >= p.maxUsesTotal) { - return false; - } - - return true; - }); - } - - /** - * Check if promotion applies to a line item - */ - private promotionAppliesToProduct( - promotion: Promotion, - line: LineItem - ): boolean { - // Check if all products - if (promotion.appliesToAllProducts) { - // Check excluded categories - if (promotion.excludedCategories?.includes(line.categoryId!)) { - return false; - } - // Check excluded products - if (promotion.excludedProducts?.includes(line.productId)) { - return false; - } - return true; - } - - // Check included categories - if (promotion.includedCategories?.includes(line.categoryId!)) { - return !promotion.excludedProducts?.includes(line.productId); - } - - // Check promotion products - const promotionProduct = promotion.products?.find( - pp => pp.productId === line.productId && - (!pp.variantId || pp.variantId === line.variantId) - ); - - return !!promotionProduct; - } - - /** - * Check if customer meets promotion requirements - */ - private customerMeetsRequirements( - promotion: Promotion, - context: PriceContext - ): boolean { - if (promotion.newCustomersOnly && !context.isNewCustomer) { - return false; - } - - if (promotion.loyaltyMembersOnly && !context.isLoyaltyMember) { - return false; - } - - if (promotion.customerLevels && promotion.customerLevels.length > 0) { - if (!context.customerLevelId || !promotion.customerLevels.includes(context.customerLevelId)) { - return false; - } - } - - return true; - } - - /** - * Calculate line discount from promotion - */ - private calculateLineDiscount( - promotion: Promotion, - line: LineItem, - promotionProduct?: PromotionProduct - ): number { - const lineSubtotal = line.unitPrice * line.quantity; - - // Use product-specific discount if available - const discountPercent = promotionProduct?.discountPercent ?? promotion.discountPercent; - const discountAmount = promotionProduct?.discountAmount ?? promotion.discountAmount; - const fixedPrice = promotionProduct?.fixedPrice; - - if (fixedPrice !== undefined && fixedPrice !== null) { - return Math.max(0, lineSubtotal - (fixedPrice * line.quantity)); - } - - if (discountPercent) { - let discount = lineSubtotal * (Number(discountPercent) / 100); - if (promotion.maxDiscount) { - discount = Math.min(discount, Number(promotion.maxDiscount)); - } - return discount; - } - - if (discountAmount) { - return Math.min(Number(discountAmount) * line.quantity, lineSubtotal); - } - - return 0; - } - - /** - * Apply Buy X Get Y promotion - */ - private applyBuyXGetY( - promotion: Promotion, - lines: LineItem[] - ): { discounts: Map; freeProducts: { productId: string; quantity: number }[] } { - const discounts = new Map(); - const freeProducts: { productId: string; quantity: number }[] = []; - - const triggerProducts = promotion.products?.filter( - pp => pp.role === ProductRole.TRIGGER || pp.role === ProductRole.BOTH - ) || []; - - const targetProducts = promotion.products?.filter( - pp => pp.role === ProductRole.TARGET || pp.role === ProductRole.BOTH - ) || []; - - // Count trigger quantity - let triggerCount = 0; - for (const line of lines) { - const isTrigger = triggerProducts.some(tp => - tp.productId === line.productId && - (!tp.variantId || tp.variantId === line.variantId) - ); - if (isTrigger) { - triggerCount += line.quantity; - } - } - - // Calculate how many times promotion can be applied - const timesApplicable = Math.floor(triggerCount / (promotion.buyQuantity ?? 1)); - const getFreeQuantity = timesApplicable * (promotion.getQuantity ?? 1); - - if (getFreeQuantity <= 0) return { discounts, freeProducts }; - - // Apply discount to target products - let remainingFree = getFreeQuantity; - const getDiscountPercent = promotion.getDiscountPercent ?? 100; - - for (let i = 0; i < lines.length && remainingFree > 0; i++) { - const line = lines[i]; - const isTarget = targetProducts.some(tp => - tp.productId === line.productId && - (!tp.variantId || tp.variantId === line.variantId) - ); - - if (isTarget) { - const qtyToDiscount = Math.min(remainingFree, line.quantity); - const discountAmount = line.unitPrice * qtyToDiscount * (getDiscountPercent / 100); - discounts.set(i, (discounts.get(i) ?? 0) + discountAmount); - remainingFree -= qtyToDiscount; - } - } - - return { discounts, freeProducts }; - } - - /** - * Apply quantity tier discount - */ - private applyQuantityTierDiscount( - promotion: Promotion, - line: LineItem - ): number { - if (!promotion.quantityTiers || promotion.quantityTiers.length === 0) { - return 0; - } - - // Find applicable tier - const sortedTiers = [...promotion.quantityTiers].sort( - (a, b) => b.minQuantity - a.minQuantity - ); - - const applicableTier = sortedTiers.find(tier => line.quantity >= tier.minQuantity); - - if (!applicableTier) return 0; - - const lineSubtotal = line.unitPrice * line.quantity; - return lineSubtotal * (applicableTier.discountPercent / 100); - } - - /** - * Calculate prices with promotions and optional coupon - */ - async calculatePrices( - context: PriceContext, - lines: LineItem[], - couponCode?: string - ): Promise { - const result: PricingResult = { - lines: [], - subtotal: 0, - totalLineDiscounts: 0, - orderDiscount: 0, - totalDiscount: 0, - total: 0, - appliedDiscounts: [], - freeProducts: [], - errors: [], - }; - - // Initialize priced lines - let lineIndex = 0; - for (const line of lines) { - const subtotal = line.unitPrice * line.quantity; - result.subtotal += subtotal; - result.lines.push({ - ...line, - lineIndex, - subtotal, - discountAmount: 0, - discountPercent: 0, - finalPrice: subtotal, - appliedPromotions: [], - }); - lineIndex++; - } - - // Get active promotions - const promotions = await this.getActivePromotions( - context.tenantId, - context.branchId, - context.channel - ); - - // Track which promotions can stack - const appliedNonStackable: string[] = []; - - // Apply promotions - for (const promotion of promotions) { - // Check stackability - if (!promotion.stackable && appliedNonStackable.length > 0) { - continue; - } - - // Check customer requirements - if (!this.customerMeetsRequirements(promotion, context)) { - continue; - } - - // Check minimum order amount - if (promotion.minOrderAmount && result.subtotal < Number(promotion.minOrderAmount)) { - continue; - } - - // Apply based on type - switch (promotion.type) { - case PromotionType.PERCENTAGE_DISCOUNT: - case PromotionType.FIXED_DISCOUNT: - this.applyLinePromotions(promotion, result, context); - break; - - case PromotionType.BUY_X_GET_Y: - const buyXGetY = this.applyBuyXGetY(promotion, lines); - for (const [idx, discount] of buyXGetY.discounts) { - result.lines[idx].discountAmount += discount; - result.lines[idx].appliedPromotions.push(promotion.code); - } - result.freeProducts.push(...buyXGetY.freeProducts.map(fp => ({ - ...fp, - sourcePromotion: promotion.code, - }))); - break; - - case PromotionType.QUANTITY_DISCOUNT: - for (const pricedLine of result.lines) { - if (this.promotionAppliesToProduct(promotion, pricedLine)) { - const discount = this.applyQuantityTierDiscount(promotion, pricedLine); - if (discount > 0) { - pricedLine.discountAmount += discount; - pricedLine.appliedPromotions.push(promotion.code); - } - } - } - break; - - case PromotionType.FLASH_SALE: - // Same as percentage/fixed but time-limited (already filtered) - this.applyLinePromotions(promotion, result, context); - break; - } - - // Track non-stackable - if (!promotion.stackable) { - appliedNonStackable.push(promotion.id); - } - - // Record applied discount - if (result.lines.some(l => l.appliedPromotions.includes(promotion.code))) { - result.appliedDiscounts.push({ - type: 'promotion', - id: promotion.id, - code: promotion.code, - name: promotion.name, - discountType: promotion.type, - discountPercent: promotion.discountPercent ? Number(promotion.discountPercent) : undefined, - discountAmount: promotion.discountAmount ? Number(promotion.discountAmount) : undefined, - }); - } - } - - // Apply coupon if provided - if (couponCode) { - const couponResult = await this.applyCoupon(context, result, couponCode); - if (couponResult.error) { - result.errors.push(couponResult.error); - } - } - - // Calculate totals - for (const line of result.lines) { - line.discountPercent = line.subtotal > 0 - ? (line.discountAmount / line.subtotal) * 100 - : 0; - line.finalPrice = line.subtotal - line.discountAmount; - result.totalLineDiscounts += line.discountAmount; - } - - result.totalDiscount = result.totalLineDiscounts + result.orderDiscount; - result.total = result.subtotal - result.totalDiscount; - - return result; - } - - /** - * Apply line-level promotions - */ - private applyLinePromotions( - promotion: Promotion, - result: PricingResult, - context: PriceContext - ): void { - for (const pricedLine of result.lines) { - if (!this.promotionAppliesToProduct(promotion, pricedLine)) { - continue; - } - - // Check minimum quantity - if (promotion.minQuantity && pricedLine.quantity < promotion.minQuantity) { - continue; - } - - const promotionProduct = promotion.products?.find( - pp => pp.productId === pricedLine.productId - ); - - const discount = this.calculateLineDiscount(promotion, pricedLine, promotionProduct); - - if (discount > 0) { - pricedLine.discountAmount += discount; - pricedLine.appliedPromotions.push(promotion.code); - } - } - } - - /** - * Validate and apply coupon - */ - async applyCoupon( - context: PriceContext, - result: PricingResult, - couponCode: string - ): Promise<{ success: boolean; error?: string }> { - const coupon = await this.couponRepository.findOne({ - where: { - tenantId: context.tenantId, - code: couponCode.toUpperCase(), - }, - }); - - if (!coupon) { - return { success: false, error: 'Coupon not found' }; - } - - // Validate status - if (coupon.status !== CouponStatus.ACTIVE) { - return { success: false, error: `Coupon is ${coupon.status}` }; - } - - // Validate dates - const now = new Date(); - if (coupon.validFrom > now) { - return { success: false, error: 'Coupon is not yet valid' }; - } - if (coupon.validUntil && coupon.validUntil < now) { - return { success: false, error: 'Coupon has expired' }; - } - - // Validate usage - if (coupon.maxUses && coupon.currentUses >= coupon.maxUses) { - return { success: false, error: 'Coupon usage limit reached' }; - } - - // Validate channel - if (context.channel === 'pos' && !coupon.posEnabled) { - return { success: false, error: 'Coupon not valid for POS' }; - } - if (context.channel !== 'pos' && !coupon.ecommerceEnabled) { - return { success: false, error: 'Coupon not valid for e-commerce' }; - } - - // Validate branch - if (coupon.includedBranches && !coupon.includedBranches.includes(context.branchId)) { - return { success: false, error: 'Coupon not valid for this branch' }; - } - - // Validate customer restrictions - if (coupon.customerSpecific) { - if (!context.customerId || !coupon.allowedCustomers?.includes(context.customerId)) { - return { success: false, error: 'Coupon not valid for this customer' }; - } - } - - if (coupon.newCustomersOnly && !context.isNewCustomer) { - return { success: false, error: 'Coupon is for new customers only' }; - } - - if (coupon.firstOrderOnly && !context.isFirstOrder) { - return { success: false, error: 'Coupon is for first order only' }; - } - - if (coupon.customerLevels && coupon.customerLevels.length > 0) { - if (!context.customerLevelId || !coupon.customerLevels.includes(context.customerLevelId)) { - return { success: false, error: 'Coupon not valid for your membership level' }; - } - } - - // Validate minimum order - if (coupon.minOrderAmount && result.subtotal < Number(coupon.minOrderAmount)) { - return { - success: false, - error: `Minimum order amount is ${coupon.minOrderAmount}`, - }; - } - - // Validate minimum items - const totalItems = result.lines.reduce((sum, l) => sum + l.quantity, 0); - if (coupon.minItems && totalItems < coupon.minItems) { - return { success: false, error: `Minimum ${coupon.minItems} items required` }; - } - - // Check stackability - if (!coupon.stackableWithPromotions && result.appliedDiscounts.some(d => d.type === 'promotion')) { - return { success: false, error: 'Coupon cannot be combined with promotions' }; - } - - // Calculate discount - let discountApplied = 0; - - switch (coupon.type) { - case CouponType.PERCENTAGE: - if (coupon.scope === CouponScope.ORDER) { - discountApplied = result.subtotal * (Number(coupon.discountPercent) / 100); - } else { - // Apply to applicable products only - for (const line of result.lines) { - if (this.couponAppliesToLine(coupon, line)) { - const lineDiscount = line.subtotal * (Number(coupon.discountPercent) / 100); - line.discountAmount += lineDiscount; - discountApplied += lineDiscount; - } - } - } - break; - - case CouponType.FIXED_AMOUNT: - discountApplied = Math.min(Number(coupon.discountAmount), result.subtotal); - break; - - case CouponType.FREE_SHIPPING: - // Would be handled in shipping calculation - break; - - case CouponType.FREE_PRODUCT: - if (coupon.freeProductId) { - result.freeProducts.push({ - productId: coupon.freeProductId, - quantity: coupon.freeProductQuantity ?? 1, - sourceCoupon: coupon.code, - }); - } - break; - } - - // Apply max discount cap - if (coupon.maxDiscount && discountApplied > Number(coupon.maxDiscount)) { - discountApplied = Number(coupon.maxDiscount); - } - - // Add to order discount or line discounts - if (coupon.scope === CouponScope.ORDER) { - result.orderDiscount += discountApplied; - } - - // Record applied coupon - result.appliedDiscounts.push({ - type: 'coupon', - id: coupon.id, - code: coupon.code, - name: coupon.name, - discountType: coupon.type, - discountPercent: coupon.discountPercent ? Number(coupon.discountPercent) : undefined, - discountAmount: coupon.discountAmount ? Number(coupon.discountAmount) : undefined, - orderDiscount: coupon.scope === CouponScope.ORDER ? discountApplied : undefined, - freeProductId: coupon.freeProductId, - freeProductQuantity: coupon.freeProductQuantity, - }); - - return { success: true }; - } - - /** - * Check if coupon applies to a line item - */ - private couponAppliesToLine(coupon: Coupon, line: LineItem): boolean { - // Check excluded products - if (coupon.excludedProducts?.includes(line.productId)) { - return false; - } - - // Check specific products - if (coupon.appliesToProducts && coupon.appliesToProducts.length > 0) { - return coupon.appliesToProducts.includes(line.productId); - } - - // Check categories - if (coupon.appliesToCategories && coupon.appliesToCategories.length > 0) { - return line.categoryId ? coupon.appliesToCategories.includes(line.categoryId) : false; - } - - // Applies to all - return true; - } - - /** - * Validate coupon code without applying - */ - async validateCoupon( - context: PriceContext, - couponCode: string, - orderAmount: number - ): Promise<{ - valid: boolean; - coupon?: Coupon; - error?: string; - potentialDiscount?: number; - }> { - const coupon = await this.couponRepository.findOne({ - where: { - tenantId: context.tenantId, - code: couponCode.toUpperCase(), - }, - }); - - if (!coupon) { - return { valid: false, error: 'Coupon not found' }; - } - - // Run basic validations - if (coupon.status !== CouponStatus.ACTIVE) { - return { valid: false, error: `Coupon is ${coupon.status}`, coupon }; - } - - const now = new Date(); - if (coupon.validFrom > now) { - return { valid: false, error: 'Coupon is not yet valid', coupon }; - } - if (coupon.validUntil && coupon.validUntil < now) { - return { valid: false, error: 'Coupon has expired', coupon }; - } - - if (coupon.minOrderAmount && orderAmount < Number(coupon.minOrderAmount)) { - return { - valid: false, - error: `Minimum order amount is ${coupon.minOrderAmount}`, - coupon, - }; - } - - // Calculate potential discount - let potentialDiscount = 0; - if (coupon.type === CouponType.PERCENTAGE) { - potentialDiscount = orderAmount * (Number(coupon.discountPercent) / 100); - } else if (coupon.type === CouponType.FIXED_AMOUNT) { - potentialDiscount = Math.min(Number(coupon.discountAmount), orderAmount); - } - - if (coupon.maxDiscount) { - potentialDiscount = Math.min(potentialDiscount, Number(coupon.maxDiscount)); - } - - return { valid: true, coupon, potentialDiscount }; - } -} diff --git a/backend/src/modules/pricing/services/promotion.service.ts b/backend/src/modules/pricing/services/promotion.service.ts deleted file mode 100644 index c8f512c..0000000 --- a/backend/src/modules/pricing/services/promotion.service.ts +++ /dev/null @@ -1,607 +0,0 @@ -import { Repository, DataSource, Between, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult, QueryOptions } from '../../../shared/types'; -import { - Promotion, - PromotionStatus, - PromotionType, - DiscountApplication, -} from '../entities/promotion.entity'; -import { PromotionProduct, ProductRole } from '../entities/promotion-product.entity'; - -export interface PromotionProductInput { - productId: string; - productCode: string; - productName: string; - variantId?: string; - role?: ProductRole; - discountPercent?: number; - discountAmount?: number; - fixedPrice?: number; - minQuantity?: number; - maxQuantity?: number; - bundleQuantity?: number; - getQuantity?: number; -} - -export interface CreatePromotionInput { - code: string; - name: string; - description?: string; - type: PromotionType; - discountApplication?: DiscountApplication; - discountPercent?: number; - discountAmount?: number; - maxDiscount?: number; - buyQuantity?: number; - getQuantity?: number; - getDiscountPercent?: number; - quantityTiers?: { minQuantity: number; discountPercent: number }[]; - bundlePrice?: number; - startDate: Date; - endDate?: Date; - validDays?: string[]; - validHoursStart?: string; - validHoursEnd?: string; - maxUsesTotal?: number; - maxUsesPerCustomer?: number; - minOrderAmount?: number; - minQuantity?: number; - appliesToAllProducts?: boolean; - includedCategories?: string[]; - excludedCategories?: string[]; - excludedProducts?: string[]; - includedBranches?: string[]; - customerLevels?: string[]; - newCustomersOnly?: boolean; - loyaltyMembersOnly?: boolean; - stackable?: boolean; - stackableWith?: string[]; - priority?: number; - displayName?: string; - badgeText?: string; - badgeColor?: string; - imageUrl?: string; - posEnabled?: boolean; - ecommerceEnabled?: boolean; - termsAndConditions?: string; - products?: PromotionProductInput[]; -} - -export interface PromotionQueryOptions extends QueryOptions { - status?: PromotionStatus; - type?: PromotionType; - branchId?: string; - startDate?: Date; - endDate?: Date; - activeOnly?: boolean; -} - -export class PromotionService extends BaseService { - constructor( - repository: Repository, - private readonly productRepository: Repository, - private readonly dataSource: DataSource - ) { - super(repository); - } - - /** - * Create a new promotion with products - */ - async createPromotion( - tenantId: string, - input: CreatePromotionInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - // Check for duplicate code - const existing = await queryRunner.manager.findOne(Promotion, { - where: { tenantId, code: input.code.toUpperCase() }, - }); - - if (existing) { - return { - success: false, - error: { code: 'DUPLICATE_CODE', message: 'Promotion code already exists' }, - }; - } - - const promotion = queryRunner.manager.create(Promotion, { - tenantId, - code: input.code.toUpperCase(), - name: input.name, - description: input.description, - type: input.type, - status: PromotionStatus.DRAFT, - discountApplication: input.discountApplication ?? DiscountApplication.LINE, - discountPercent: input.discountPercent, - discountAmount: input.discountAmount, - maxDiscount: input.maxDiscount, - buyQuantity: input.buyQuantity, - getQuantity: input.getQuantity, - getDiscountPercent: input.getDiscountPercent, - quantityTiers: input.quantityTiers, - bundlePrice: input.bundlePrice, - startDate: input.startDate, - endDate: input.endDate, - validDays: input.validDays, - validHoursStart: input.validHoursStart, - validHoursEnd: input.validHoursEnd, - maxUsesTotal: input.maxUsesTotal, - maxUsesPerCustomer: input.maxUsesPerCustomer, - minOrderAmount: input.minOrderAmount, - minQuantity: input.minQuantity, - appliesToAllProducts: input.appliesToAllProducts ?? false, - includedCategories: input.includedCategories, - excludedCategories: input.excludedCategories, - excludedProducts: input.excludedProducts, - includedBranches: input.includedBranches, - customerLevels: input.customerLevels, - newCustomersOnly: input.newCustomersOnly ?? false, - loyaltyMembersOnly: input.loyaltyMembersOnly ?? false, - stackable: input.stackable ?? false, - stackableWith: input.stackableWith, - priority: input.priority ?? 0, - displayName: input.displayName, - badgeText: input.badgeText, - badgeColor: input.badgeColor ?? '#ff0000', - imageUrl: input.imageUrl, - posEnabled: input.posEnabled ?? true, - ecommerceEnabled: input.ecommerceEnabled ?? true, - termsAndConditions: input.termsAndConditions, - createdBy: userId, - }); - - const savedPromotion = await queryRunner.manager.save(promotion); - - // Create promotion products - if (input.products && input.products.length > 0) { - for (const productInput of input.products) { - const promotionProduct = queryRunner.manager.create(PromotionProduct, { - tenantId, - promotionId: savedPromotion.id, - productId: productInput.productId, - productCode: productInput.productCode, - productName: productInput.productName, - variantId: productInput.variantId, - role: productInput.role ?? ProductRole.BOTH, - discountPercent: productInput.discountPercent, - discountAmount: productInput.discountAmount, - fixedPrice: productInput.fixedPrice, - minQuantity: productInput.minQuantity ?? 1, - maxQuantity: productInput.maxQuantity, - bundleQuantity: productInput.bundleQuantity ?? 1, - getQuantity: productInput.getQuantity, - }); - await queryRunner.manager.save(promotionProduct); - } - } - - await queryRunner.commitTransaction(); - - // Reload with products - const result = await this.repository.findOne({ - where: { id: savedPromotion.id, tenantId }, - relations: ['products'], - }); - - return { success: true, data: result! }; - } catch (error) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_PROMOTION_ERROR', - message: error instanceof Error ? error.message : 'Failed to create promotion', - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Update promotion - */ - async updatePromotion( - tenantId: string, - id: string, - input: Partial, - userId: string - ): Promise> { - const promotion = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!promotion) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Promotion not found' }, - }; - } - - // Don't allow editing active promotions (only some fields) - if (promotion.status === PromotionStatus.ACTIVE) { - const allowedFields = ['endDate', 'maxUsesTotal', 'priority', 'status']; - const inputKeys = Object.keys(input); - const hasDisallowedFields = inputKeys.some(k => !allowedFields.includes(k)); - - if (hasDisallowedFields) { - return { - success: false, - error: { - code: 'PROMOTION_ACTIVE', - message: 'Cannot modify active promotion. Only endDate, maxUsesTotal, priority, and status can be changed.', - }, - }; - } - } - - // Update allowed fields - const { products, ...updateData } = input; - Object.assign(promotion, updateData, { updatedBy: userId }); - - const saved = await this.repository.save(promotion); - return { success: true, data: saved }; - } - - /** - * Activate promotion - */ - async activatePromotion( - tenantId: string, - id: string, - userId: string - ): Promise> { - const promotion = await this.repository.findOne({ - where: { id, tenantId }, - relations: ['products'], - }); - - if (!promotion) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Promotion not found' }, - }; - } - - if (promotion.status !== PromotionStatus.DRAFT && promotion.status !== PromotionStatus.SCHEDULED) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Can only activate draft or scheduled promotions' }, - }; - } - - // Validate promotion has required data - if (!promotion.appliesToAllProducts && (!promotion.products || promotion.products.length === 0)) { - if (!promotion.includedCategories || promotion.includedCategories.length === 0) { - return { - success: false, - error: { code: 'NO_PRODUCTS', message: 'Promotion must have products or categories defined' }, - }; - } - } - - const now = new Date(); - if (promotion.startDate > now) { - promotion.status = PromotionStatus.SCHEDULED; - } else { - promotion.status = PromotionStatus.ACTIVE; - } - - promotion.updatedBy = userId; - const saved = await this.repository.save(promotion); - - return { success: true, data: saved }; - } - - /** - * Pause promotion - */ - async pausePromotion( - tenantId: string, - id: string, - userId: string - ): Promise> { - const promotion = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!promotion) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Promotion not found' }, - }; - } - - if (promotion.status !== PromotionStatus.ACTIVE) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Can only pause active promotions' }, - }; - } - - promotion.status = PromotionStatus.PAUSED; - promotion.updatedBy = userId; - - const saved = await this.repository.save(promotion); - return { success: true, data: saved }; - } - - /** - * End promotion - */ - async endPromotion( - tenantId: string, - id: string, - userId: string - ): Promise> { - const promotion = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!promotion) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Promotion not found' }, - }; - } - - if ([PromotionStatus.ENDED, PromotionStatus.CANCELLED].includes(promotion.status)) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Promotion is already ended or cancelled' }, - }; - } - - promotion.status = PromotionStatus.ENDED; - promotion.endDate = new Date(); - promotion.updatedBy = userId; - - const saved = await this.repository.save(promotion); - return { success: true, data: saved }; - } - - /** - * Cancel promotion - */ - async cancelPromotion( - tenantId: string, - id: string, - userId: string - ): Promise> { - const promotion = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!promotion) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Promotion not found' }, - }; - } - - if (promotion.status === PromotionStatus.CANCELLED) { - return { - success: false, - error: { code: 'ALREADY_CANCELLED', message: 'Promotion is already cancelled' }, - }; - } - - promotion.status = PromotionStatus.CANCELLED; - promotion.updatedBy = userId; - - const saved = await this.repository.save(promotion); - return { success: true, data: saved }; - } - - /** - * Add products to promotion - */ - async addProducts( - tenantId: string, - promotionId: string, - products: PromotionProductInput[], - userId: string - ): Promise> { - const promotion = await this.repository.findOne({ - where: { id: promotionId, tenantId }, - }); - - if (!promotion) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Promotion not found' }, - }; - } - - if (promotion.status === PromotionStatus.ACTIVE) { - return { - success: false, - error: { code: 'PROMOTION_ACTIVE', message: 'Cannot add products to active promotion' }, - }; - } - - const created: PromotionProduct[] = []; - - for (const productInput of products) { - // Check if already exists - const existing = await this.productRepository.findOne({ - where: { - tenantId, - promotionId, - productId: productInput.productId, - variantId: productInput.variantId ?? undefined, - }, - }); - - if (existing) continue; - - const promotionProduct = this.productRepository.create({ - tenantId, - promotionId, - productId: productInput.productId, - productCode: productInput.productCode, - productName: productInput.productName, - variantId: productInput.variantId, - role: productInput.role ?? ProductRole.BOTH, - discountPercent: productInput.discountPercent, - discountAmount: productInput.discountAmount, - fixedPrice: productInput.fixedPrice, - minQuantity: productInput.minQuantity ?? 1, - maxQuantity: productInput.maxQuantity, - bundleQuantity: productInput.bundleQuantity ?? 1, - getQuantity: productInput.getQuantity, - }); - - const saved = await this.productRepository.save(promotionProduct); - created.push(saved); - } - - return { success: true, data: created }; - } - - /** - * Remove product from promotion - */ - async removeProduct( - tenantId: string, - promotionId: string, - productId: string - ): Promise> { - const promotion = await this.repository.findOne({ - where: { id: promotionId, tenantId }, - }); - - if (!promotion) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Promotion not found' }, - }; - } - - if (promotion.status === PromotionStatus.ACTIVE) { - return { - success: false, - error: { code: 'PROMOTION_ACTIVE', message: 'Cannot remove products from active promotion' }, - }; - } - - await this.productRepository.delete({ - tenantId, - promotionId, - productId, - }); - - return { success: true, data: true }; - } - - /** - * Find promotions with filters - */ - async findPromotions( - tenantId: string, - options: PromotionQueryOptions - ): Promise<{ data: Promotion[]; total: number }> { - const qb = this.repository.createQueryBuilder('promotion') - .leftJoinAndSelect('promotion.products', 'products') - .where('promotion.tenantId = :tenantId', { tenantId }); - - if (options.status) { - qb.andWhere('promotion.status = :status', { status: options.status }); - } - - if (options.type) { - qb.andWhere('promotion.type = :type', { type: options.type }); - } - - if (options.branchId) { - qb.andWhere('(promotion.includedBranches IS NULL OR promotion.includedBranches @> :branchArray)', { - branchArray: JSON.stringify([options.branchId]), - }); - } - - if (options.activeOnly) { - const now = new Date(); - qb.andWhere('promotion.status = :activeStatus', { activeStatus: PromotionStatus.ACTIVE }) - .andWhere('promotion.startDate <= :now', { now }) - .andWhere('(promotion.endDate IS NULL OR promotion.endDate >= :now)', { now }); - } - - if (options.startDate && options.endDate) { - qb.andWhere('promotion.startDate BETWEEN :startDate AND :endDate', { - startDate: options.startDate, - endDate: options.endDate, - }); - } - - const page = options.page ?? 1; - const limit = options.limit ?? 20; - qb.skip((page - 1) * limit).take(limit); - qb.orderBy('promotion.priority', 'DESC') - .addOrderBy('promotion.createdAt', 'DESC'); - - const [data, total] = await qb.getManyAndCount(); - return { data, total }; - } - - /** - * Get promotion with products - */ - async getPromotionWithProducts( - tenantId: string, - id: string - ): Promise { - return this.repository.findOne({ - where: { id, tenantId }, - relations: ['products'], - }); - } - - /** - * Increment usage count - */ - async incrementUsage( - tenantId: string, - id: string - ): Promise { - await this.repository.increment( - { id, tenantId }, - 'currentUses', - 1 - ); - } - - /** - * Get promotion statistics - */ - async getPromotionStats( - tenantId: string, - id: string - ): Promise<{ - totalUses: number; - totalDiscount: number; - averageDiscount: number; - }> { - const promotion = await this.repository.findOne({ - where: { id, tenantId }, - }); - - if (!promotion) { - return { totalUses: 0, totalDiscount: 0, averageDiscount: 0 }; - } - - // Would need to query order data for actual discount amounts - // For now, return basic usage stats - return { - totalUses: promotion.currentUses, - totalDiscount: 0, // Would calculate from orders - averageDiscount: 0, - }; - } -} diff --git a/backend/src/modules/pricing/validation/index.ts b/backend/src/modules/pricing/validation/index.ts deleted file mode 100644 index 915fd2c..0000000 --- a/backend/src/modules/pricing/validation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './pricing.schema'; diff --git a/backend/src/modules/pricing/validation/pricing.schema.ts b/backend/src/modules/pricing/validation/pricing.schema.ts deleted file mode 100644 index 1dcbefe..0000000 --- a/backend/src/modules/pricing/validation/pricing.schema.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { z } from 'zod'; -import { - uuidSchema, - moneySchema, - percentSchema, - paginationSchema, -} from '../../../shared/validation/common.schema'; - -// Enums -export const promotionTypeEnum = z.enum([ - 'percentage_discount', - 'fixed_discount', - 'buy_x_get_y', - 'bundle', - 'flash_sale', - 'quantity_discount', - 'free_shipping', - 'gift_with_purchase', -]); - -export const promotionStatusEnum = z.enum([ - 'draft', - 'scheduled', - 'active', - 'paused', - 'ended', - 'cancelled', -]); - -export const discountApplicationEnum = z.enum(['order', 'line', 'shipping']); - -export const productRoleEnum = z.enum(['trigger', 'target', 'both', 'gift']); - -export const couponTypeEnum = z.enum([ - 'percentage', - 'fixed_amount', - 'free_shipping', - 'free_product', -]); - -export const couponScopeEnum = z.enum(['order', 'product', 'category', 'shipping']); -export const couponStatusEnum = z.enum(['active', 'inactive', 'expired', 'depleted']); - -// ==================== PROMOTION SCHEMAS ==================== - -// Quantity tier schema -export const quantityTierSchema = z.object({ - minQuantity: z.number().int().positive(), - discountPercent: percentSchema, -}); - -// Promotion product schema -export const promotionProductSchema = z.object({ - productId: uuidSchema, - productCode: z.string().min(1).max(50), - productName: z.string().min(1).max(200), - variantId: uuidSchema.optional(), - role: productRoleEnum.optional(), - discountPercent: percentSchema.optional(), - discountAmount: moneySchema.optional(), - fixedPrice: moneySchema.optional(), - minQuantity: z.number().int().positive().optional(), - maxQuantity: z.number().int().positive().optional(), - bundleQuantity: z.number().int().positive().optional(), - getQuantity: z.number().int().positive().optional(), -}); - -// Create promotion schema -export const createPromotionSchema = z.object({ - code: z.string().min(1).max(30).regex(/^[A-Z0-9_-]+$/i, 'Code must be alphanumeric'), - name: z.string().min(1).max(100), - description: z.string().max(1000).optional(), - type: promotionTypeEnum, - discountApplication: discountApplicationEnum.optional(), - discountPercent: percentSchema.optional(), - discountAmount: moneySchema.optional(), - maxDiscount: moneySchema.optional(), - buyQuantity: z.number().int().positive().optional(), - getQuantity: z.number().int().positive().optional(), - getDiscountPercent: percentSchema.optional(), - quantityTiers: z.array(quantityTierSchema).optional(), - bundlePrice: moneySchema.optional(), - startDate: z.coerce.date(), - endDate: z.coerce.date().optional(), - validDays: z.array(z.enum(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'])).optional(), - validHoursStart: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/, 'Invalid time format').optional(), - validHoursEnd: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/, 'Invalid time format').optional(), - maxUsesTotal: z.number().int().positive().optional(), - maxUsesPerCustomer: z.number().int().positive().optional(), - minOrderAmount: moneySchema.optional(), - minQuantity: z.number().int().positive().optional(), - appliesToAllProducts: z.boolean().optional(), - includedCategories: z.array(uuidSchema).optional(), - excludedCategories: z.array(uuidSchema).optional(), - excludedProducts: z.array(uuidSchema).optional(), - includedBranches: z.array(uuidSchema).optional(), - customerLevels: z.array(uuidSchema).optional(), - newCustomersOnly: z.boolean().optional(), - loyaltyMembersOnly: z.boolean().optional(), - stackable: z.boolean().optional(), - stackableWith: z.array(uuidSchema).optional(), - priority: z.number().int().min(0).max(100).optional(), - displayName: z.string().max(100).optional(), - badgeText: z.string().max(30).optional(), - badgeColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid color format').optional(), - imageUrl: z.string().url().optional(), - posEnabled: z.boolean().optional(), - ecommerceEnabled: z.boolean().optional(), - termsAndConditions: z.string().max(5000).optional(), - products: z.array(promotionProductSchema).optional(), -}); - -// Update promotion schema -export const updatePromotionSchema = createPromotionSchema.partial().omit({ code: true }); - -// Add products schema -export const addPromotionProductsSchema = z.object({ - products: z.array(promotionProductSchema).min(1), -}); - -// List promotions query schema -export const listPromotionsQuerySchema = paginationSchema.extend({ - status: promotionStatusEnum.optional(), - type: promotionTypeEnum.optional(), - branchId: uuidSchema.optional(), - activeOnly: z.coerce.boolean().optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), -}); - -// ==================== COUPON SCHEMAS ==================== - -// Create coupon schema -export const createCouponSchema = z.object({ - code: z.string().min(1).max(50).regex(/^[A-Z0-9_-]+$/i, 'Code must be alphanumeric'), - name: z.string().min(1).max(100), - description: z.string().max(1000).optional(), - type: couponTypeEnum, - scope: couponScopeEnum.optional(), - discountPercent: percentSchema.optional(), - discountAmount: moneySchema.optional(), - maxDiscount: moneySchema.optional(), - freeProductId: uuidSchema.optional(), - freeProductQuantity: z.number().int().positive().optional(), - validFrom: z.coerce.date(), - validUntil: z.coerce.date().optional(), - maxUses: z.number().int().positive().optional(), - maxUsesPerCustomer: z.number().int().positive().optional(), - minOrderAmount: moneySchema.optional(), - minItems: z.number().int().positive().optional(), - appliesToCategories: z.array(uuidSchema).optional(), - appliesToProducts: z.array(uuidSchema).optional(), - excludedProducts: z.array(uuidSchema).optional(), - includedBranches: z.array(uuidSchema).optional(), - customerSpecific: z.boolean().optional(), - allowedCustomers: z.array(uuidSchema).optional(), - customerLevels: z.array(uuidSchema).optional(), - newCustomersOnly: z.boolean().optional(), - firstOrderOnly: z.boolean().optional(), - campaignId: uuidSchema.optional(), - campaignName: z.string().max(100).optional(), - stackableWithPromotions: z.boolean().optional(), - stackableWithCoupons: z.boolean().optional(), - posEnabled: z.boolean().optional(), - ecommerceEnabled: z.boolean().optional(), - isSingleUse: z.boolean().optional(), - autoApply: z.boolean().optional(), - publicVisible: z.boolean().optional(), - termsAndConditions: z.string().max(5000).optional(), -}); - -// Update coupon schema -export const updateCouponSchema = createCouponSchema.partial().omit({ code: true }); - -// Generate bulk coupons schema -export const generateBulkCouponsSchema = z.object({ - quantity: z.number().int().min(1).max(1000), - prefix: z.string().min(1).max(10).regex(/^[A-Z0-9]+$/i).default('CPN'), - baseInput: createCouponSchema.omit({ code: true }), -}); - -// Redeem coupon schema -export const redeemCouponSchema = z.object({ - orderId: uuidSchema, - orderNumber: z.string().min(1).max(30), - customerId: uuidSchema.optional(), - branchId: uuidSchema, - orderAmount: moneySchema.positive(), - discountApplied: moneySchema, - channel: z.enum(['pos', 'ecommerce', 'mobile']), - ipAddress: z.string().ip().optional(), -}); - -// Validate coupon schema -export const validateCouponSchema = z.object({ - code: z.string().min(1).max(50), - orderAmount: moneySchema.positive(), -}); - -// Reverse redemption schema -export const reverseRedemptionSchema = z.object({ - reason: z.string().min(1, 'Reason is required').max(255), -}); - -// List coupons query schema -export const listCouponsQuerySchema = paginationSchema.extend({ - status: couponStatusEnum.optional(), - type: couponTypeEnum.optional(), - campaignId: uuidSchema.optional(), - branchId: uuidSchema.optional(), - activeOnly: z.coerce.boolean().optional(), - publicOnly: z.coerce.boolean().optional(), -}); - -// ==================== PRICE ENGINE SCHEMAS ==================== - -// Line item schema for price calculation -export const lineItemSchema = z.object({ - productId: uuidSchema, - productCode: z.string().min(1).max(50), - productName: z.string().min(1).max(200), - variantId: uuidSchema.optional(), - categoryId: uuidSchema.optional(), - quantity: z.number().positive(), - unitPrice: moneySchema.positive(), - originalPrice: moneySchema.positive(), -}); - -// Calculate prices schema -export const calculatePricesSchema = z.object({ - branchId: uuidSchema, - customerId: uuidSchema.optional(), - customerLevelId: uuidSchema.optional(), - isNewCustomer: z.boolean().optional(), - isLoyaltyMember: z.boolean().optional(), - isFirstOrder: z.boolean().optional(), - channel: z.enum(['pos', 'ecommerce', 'mobile']), - lines: z.array(lineItemSchema).min(1), - couponCode: z.string().max(50).optional(), -}); - -// Types -export type CreatePromotionInput = z.infer; -export type UpdatePromotionInput = z.infer; -export type PromotionProductInput = z.infer; -export type ListPromotionsQuery = z.infer; -export type CreateCouponInput = z.infer; -export type UpdateCouponInput = z.infer; -export type GenerateBulkCouponsInput = z.infer; -export type RedeemCouponInput = z.infer; -export type ListCouponsQuery = z.infer; -export type CalculatePricesInput = z.infer; diff --git a/backend/src/modules/purchases/controllers/index.ts b/backend/src/modules/purchases/controllers/index.ts deleted file mode 100644 index 6e9c295..0000000 --- a/backend/src/modules/purchases/controllers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './purchases.controller'; diff --git a/backend/src/modules/purchases/controllers/purchases.controller.ts b/backend/src/modules/purchases/controllers/purchases.controller.ts deleted file mode 100644 index 7e82a44..0000000 --- a/backend/src/modules/purchases/controllers/purchases.controller.ts +++ /dev/null @@ -1,741 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { DataSource } from 'typeorm'; -import { AuthenticatedRequest } from '../../../shared/types'; -import { - SupplierOrderService, - GoodsReceiptService, - PurchaseSuggestionService, -} from '../services'; -import { - createSupplierOrderSchema, - updateSupplierOrderSchema, - submitForApprovalSchema, - approveOrderSchema, - rejectOrderSchema, - sendToSupplierSchema, - supplierConfirmationSchema, - cancelOrderSchema, - listSupplierOrdersQuerySchema, - createGoodsReceiptSchema, - updateGoodsReceiptSchema, - createReceiptFromOrderSchema, - completeQualityCheckSchema, - postReceiptSchema, - cancelReceiptSchema, - listReceiptsQuerySchema, - createSuggestionSchema, - updateSuggestionSchema, - reviewSuggestionSchema, - bulkReviewSuggestionsSchema, - convertSuggestionsToOrdersSchema, - generateSuggestionsSchema, - listSuggestionsQuerySchema, - purchasesSummaryReportSchema, - supplierPerformanceReportSchema, -} from '../validation/purchases.schema'; -import { idParamSchema } from '../../../shared/validation/common.schema'; - -export class PurchasesController { - private supplierOrderService: SupplierOrderService; - private goodsReceiptService: GoodsReceiptService; - private purchaseSuggestionService: PurchaseSuggestionService; - - constructor(dataSource: DataSource) { - this.supplierOrderService = new SupplierOrderService(dataSource); - this.goodsReceiptService = new GoodsReceiptService(dataSource, this.supplierOrderService); - this.purchaseSuggestionService = new PurchaseSuggestionService(dataSource, this.supplierOrderService); - } - - // ==================== SUPPLIER ORDERS ==================== - - /** - * Create supplier order - */ - createOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const data = createSupplierOrderSchema.parse(req.body); - const result = await this.supplierOrderService.createOrder( - req.tenantId!, - data, - req.user!.id - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.status(201).json(result); - } catch (error) { - next(error); - } - }; - - /** - * Get supplier order by ID - */ - getOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const order = await this.supplierOrderService.findById(req.tenantId!, id, ['lines']); - - if (!order) { - return res.status(404).json({ - success: false, - error: { code: 'NOT_FOUND', message: 'Order not found' }, - }); - } - - res.json({ success: true, data: order }); - } catch (error) { - next(error); - } - }; - - /** - * Update supplier order - */ - updateOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const data = updateSupplierOrderSchema.parse(req.body); - - const result = await this.supplierOrderService.updateOrder( - req.tenantId!, - id, - data, - req.user!.id - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * List supplier orders - */ - listOrders = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const query = listSupplierOrdersQuerySchema.parse(req.query); - const result = await this.supplierOrderService.listOrders( - req.tenantId!, - req.branchId!, - query - ); - - res.json({ success: true, ...result }); - } catch (error) { - next(error); - } - }; - - /** - * Submit order for approval - */ - submitForApproval = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const { notes } = submitForApprovalSchema.parse(req.body); - - const result = await this.supplierOrderService.submitForApproval( - req.tenantId!, - id, - req.user!.id, - notes - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Approve order - */ - approveOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const { notes } = approveOrderSchema.parse(req.body); - - const result = await this.supplierOrderService.approveOrder( - req.tenantId!, - id, - req.user!.id, - notes - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Reject order - */ - rejectOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const { reason } = rejectOrderSchema.parse(req.body); - - const result = await this.supplierOrderService.rejectOrder( - req.tenantId!, - id, - req.user!.id, - reason - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Send order to supplier - */ - sendToSupplier = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const { method, emailTo, notes } = sendToSupplierSchema.parse(req.body); - - const result = await this.supplierOrderService.sendToSupplier( - req.tenantId!, - id, - req.user!.id, - method, - emailTo, - notes - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Confirm order by supplier - */ - supplierConfirmation = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const { supplierReference, expectedDate, notes } = supplierConfirmationSchema.parse(req.body); - - const result = await this.supplierOrderService.supplierConfirmation( - req.tenantId!, - id, - supplierReference, - expectedDate, - notes - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Cancel order - */ - cancelOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const { reason } = cancelOrderSchema.parse(req.body); - - const result = await this.supplierOrderService.cancelOrder( - req.tenantId!, - id, - req.user!.id, - reason - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Get order statistics - */ - getOrderStats = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const query = purchasesSummaryReportSchema.parse(req.query); - const stats = await this.supplierOrderService.getOrderStats( - req.tenantId!, - req.branchId!, - query.startDate, - query.endDate - ); - - res.json({ success: true, data: stats }); - } catch (error) { - next(error); - } - }; - - // ==================== GOODS RECEIPTS ==================== - - /** - * Create goods receipt - */ - createReceipt = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const data = createGoodsReceiptSchema.parse(req.body); - const result = await this.goodsReceiptService.createReceipt( - req.tenantId!, - data, - req.user!.id - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.status(201).json(result); - } catch (error) { - next(error); - } - }; - - /** - * Create receipt from order - */ - createReceiptFromOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const data = createReceiptFromOrderSchema.parse(req.body); - const result = await this.goodsReceiptService.createReceiptFromOrder( - req.tenantId!, - data, - req.user!.id - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.status(201).json(result); - } catch (error) { - next(error); - } - }; - - /** - * Get goods receipt by ID - */ - getReceipt = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const receipt = await this.goodsReceiptService.findById(req.tenantId!, id, ['lines']); - - if (!receipt) { - return res.status(404).json({ - success: false, - error: { code: 'NOT_FOUND', message: 'Receipt not found' }, - }); - } - - res.json({ success: true, data: receipt }); - } catch (error) { - next(error); - } - }; - - /** - * Update goods receipt - */ - updateReceipt = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const data = updateGoodsReceiptSchema.parse(req.body); - - const result = await this.goodsReceiptService.updateReceipt( - req.tenantId!, - id, - data, - req.user!.id - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * List goods receipts - */ - listReceipts = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const query = listReceiptsQuerySchema.parse(req.query); - const result = await this.goodsReceiptService.listReceipts( - req.tenantId!, - req.branchId!, - query - ); - - res.json({ success: true, ...result }); - } catch (error) { - next(error); - } - }; - - /** - * Complete quality check - */ - completeQualityCheck = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const { passed, notes, lineChecks } = completeQualityCheckSchema.parse(req.body); - - const result = await this.goodsReceiptService.completeQualityCheck( - req.tenantId!, - id, - req.user!.id, - passed, - notes, - lineChecks - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Post receipt - */ - postReceipt = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const { notes } = postReceiptSchema.parse(req.body); - - const result = await this.goodsReceiptService.postReceipt( - req.tenantId!, - id, - req.user!.id, - notes - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Cancel receipt - */ - cancelReceipt = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const { reason } = cancelReceiptSchema.parse(req.body); - - const result = await this.goodsReceiptService.cancelReceipt( - req.tenantId!, - id, - req.user!.id, - reason - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Get receipt statistics - */ - getReceiptStats = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const query = purchasesSummaryReportSchema.parse(req.query); - const stats = await this.goodsReceiptService.getReceiptStats( - req.tenantId!, - req.branchId!, - query.startDate, - query.endDate - ); - - res.json({ success: true, data: stats }); - } catch (error) { - next(error); - } - }; - - // ==================== PURCHASE SUGGESTIONS ==================== - - /** - * Create suggestion - */ - createSuggestion = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const data = createSuggestionSchema.parse(req.body); - const result = await this.purchaseSuggestionService.createSuggestion( - req.tenantId!, - data, - req.user!.id - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.status(201).json(result); - } catch (error) { - next(error); - } - }; - - /** - * Get suggestion by ID - */ - getSuggestion = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const suggestion = await this.purchaseSuggestionService.findById(req.tenantId!, id); - - if (!suggestion) { - return res.status(404).json({ - success: false, - error: { code: 'NOT_FOUND', message: 'Suggestion not found' }, - }); - } - - res.json({ success: true, data: suggestion }); - } catch (error) { - next(error); - } - }; - - /** - * Update suggestion - */ - updateSuggestion = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const data = updateSuggestionSchema.parse(req.body); - - const result = await this.purchaseSuggestionService.updateSuggestion( - req.tenantId!, - id, - data, - req.user!.id - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * List suggestions - */ - listSuggestions = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const query = listSuggestionsQuerySchema.parse(req.query); - const result = await this.purchaseSuggestionService.listSuggestions( - req.tenantId!, - req.branchId!, - query - ); - - res.json({ success: true, ...result }); - } catch (error) { - next(error); - } - }; - - /** - * Review suggestion - */ - reviewSuggestion = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - const review = reviewSuggestionSchema.parse(req.body); - - const result = await this.purchaseSuggestionService.reviewSuggestion( - req.tenantId!, - id, - req.user!.id, - review - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Bulk review suggestions - */ - bulkReviewSuggestions = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { suggestionIds, action, notes } = bulkReviewSuggestionsSchema.parse(req.body); - - const result = await this.purchaseSuggestionService.bulkReviewSuggestions( - req.tenantId!, - suggestionIds, - req.user!.id, - action, - notes - ); - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Convert suggestions to orders - */ - convertToOrders = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const data = convertSuggestionsToOrdersSchema.parse(req.body); - - const result = await this.purchaseSuggestionService.convertToOrders( - req.tenantId!, - data, - req.user!.id - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Generate suggestions - */ - generateSuggestions = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const data = generateSuggestionsSchema.parse(req.body); - - const result = await this.purchaseSuggestionService.generateSuggestions( - req.tenantId!, - req.branchId!, - data.warehouseId || req.branchId!, // Use branch as default warehouse - data, - req.user!.id - ); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json(result); - } catch (error) { - next(error); - } - }; - - /** - * Get suggestion statistics - */ - getSuggestionStats = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const stats = await this.purchaseSuggestionService.getSuggestionStats( - req.tenantId!, - req.branchId! - ); - - res.json({ success: true, data: stats }); - } catch (error) { - next(error); - } - }; - - /** - * Delete suggestion - */ - deleteSuggestion = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - try { - const { id } = idParamSchema.parse(req.params); - - const result = await this.purchaseSuggestionService.delete(req.tenantId!, id); - - if (!result.success) { - return res.status(400).json(result); - } - - res.json({ success: true, message: 'Suggestion deleted' }); - } catch (error) { - next(error); - } - }; -} - -// Singleton instance (will be initialized with dataSource) -let purchasesController: PurchasesController; - -export const initializePurchasesController = (dataSource: DataSource) => { - purchasesController = new PurchasesController(dataSource); - return purchasesController; -}; - -export { purchasesController }; diff --git a/backend/src/modules/purchases/entities/goods-receipt-line.entity.ts b/backend/src/modules/purchases/entities/goods-receipt-line.entity.ts deleted file mode 100644 index 99da0b0..0000000 --- a/backend/src/modules/purchases/entities/goods-receipt-line.entity.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { GoodsReceipt } from './goods-receipt.entity'; - -export enum ReceiptLineStatus { - PENDING = 'pending', - RECEIVED = 'received', - REJECTED = 'rejected', - PARTIAL = 'partial', -} - -export enum QualityStatus { - NOT_CHECKED = 'not_checked', - PASSED = 'passed', - FAILED = 'failed', - PARTIAL = 'partial', -} - -@Entity('goods_receipt_lines', { schema: 'retail' }) -@Index(['tenantId', 'receiptId']) -@Index(['tenantId', 'productId']) -@Index(['tenantId', 'orderLineId']) -export class GoodsReceiptLine { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'receipt_id', type: 'uuid' }) - receiptId: string; - - @Column({ name: 'line_number', type: 'int' }) - lineNumber: number; - - @Column({ - type: 'enum', - enum: ReceiptLineStatus, - default: ReceiptLineStatus.RECEIVED, - }) - status: ReceiptLineStatus; - - // Order line reference - @Column({ name: 'order_line_id', type: 'uuid', nullable: true }) - orderLineId: string; - - // Product (from erp-core) - @Column({ name: 'product_id', type: 'uuid' }) - productId: string; - - @Column({ name: 'product_code', length: 50 }) - productCode: string; - - @Column({ name: 'product_name', length: 200 }) - productName: string; - - // Variant - @Column({ name: 'variant_id', type: 'uuid', nullable: true }) - variantId: string; - - @Column({ name: 'variant_name', length: 200, nullable: true }) - variantName: string; - - // UOM - @Column({ name: 'uom_id', type: 'uuid', nullable: true }) - uomId: string; - - @Column({ name: 'uom_name', length: 20, default: 'PZA' }) - uomName: string; - - // Quantities - @Column({ name: 'quantity_expected', type: 'decimal', precision: 15, scale: 4, nullable: true }) - quantityExpected: number; - - @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4 }) - quantityReceived: number; - - @Column({ name: 'quantity_rejected', type: 'decimal', precision: 15, scale: 4, default: 0 }) - quantityRejected: number; - - @Column({ name: 'quantity_accepted', type: 'decimal', precision: 15, scale: 4 }) - quantityAccepted: number; - - // Pricing - @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4 }) - unitCost: number; - - @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) - taxRate: number; - - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; - - // Totals - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - // Storage location - @Column({ name: 'location_id', type: 'uuid', nullable: true }) - locationId: string; - - @Column({ name: 'location_code', length: 50, nullable: true }) - locationCode: string; - - // Lot/Serial tracking - @Column({ name: 'lot_number', length: 50, nullable: true }) - lotNumber: string; - - @Column({ name: 'serial_numbers', type: 'jsonb', nullable: true }) - serialNumbers: string[]; - - @Column({ name: 'expiry_date', type: 'date', nullable: true }) - expiryDate: Date; - - @Column({ name: 'manufacture_date', type: 'date', nullable: true }) - manufactureDate: Date; - - // Quality check - @Column({ - name: 'quality_status', - type: 'enum', - enum: QualityStatus, - default: QualityStatus.NOT_CHECKED, - }) - qualityStatus: QualityStatus; - - @Column({ name: 'quality_notes', type: 'text', nullable: true }) - qualityNotes: string; - - @Column({ name: 'rejection_reason', length: 255, nullable: true }) - rejectionReason: string; - - // Discrepancy - @Column({ name: 'has_discrepancy', type: 'boolean', default: false }) - hasDiscrepancy: boolean; - - @Column({ name: 'discrepancy_type', length: 30, nullable: true }) - discrepancyType: 'shortage' | 'overage' | 'damage' | 'wrong_item' | 'other'; - - @Column({ name: 'discrepancy_notes', type: 'text', nullable: true }) - discrepancyNotes: string; - - // Stock before receipt - @Column({ name: 'stock_before', type: 'decimal', precision: 15, scale: 4, nullable: true }) - stockBefore: number; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - // Relations - @ManyToOne(() => GoodsReceipt, (receipt) => receipt.lines) - @JoinColumn({ name: 'receipt_id' }) - receipt: GoodsReceipt; -} diff --git a/backend/src/modules/purchases/entities/goods-receipt.entity.ts b/backend/src/modules/purchases/entities/goods-receipt.entity.ts deleted file mode 100644 index f6d4066..0000000 --- a/backend/src/modules/purchases/entities/goods-receipt.entity.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { GoodsReceiptLine } from './goods-receipt-line.entity'; - -export enum ReceiptStatus { - DRAFT = 'draft', - IN_PROGRESS = 'in_progress', - COMPLETED = 'completed', - POSTED = 'posted', - CANCELLED = 'cancelled', -} - -@Entity('goods_receipts', { schema: 'retail' }) -@Index(['tenantId', 'branchId', 'status']) -@Index(['tenantId', 'supplierId']) -@Index(['tenantId', 'supplierOrderId']) -@Index(['tenantId', 'number'], { unique: true }) -@Index(['tenantId', 'receiptDate']) -export class GoodsReceipt { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - @Column({ name: 'warehouse_id', type: 'uuid' }) - warehouseId: string; - - @Column({ length: 30, unique: true }) - number: string; - - @Column({ - type: 'enum', - enum: ReceiptStatus, - default: ReceiptStatus.DRAFT, - }) - status: ReceiptStatus; - - @Column({ name: 'receipt_date', type: 'date' }) - receiptDate: Date; - - // Supplier Order reference - @Column({ name: 'supplier_order_id', type: 'uuid', nullable: true }) - supplierOrderId: string; - - @Column({ name: 'supplier_order_number', length: 30, nullable: true }) - supplierOrderNumber: string; - - // Supplier (from erp-core partners) - @Column({ name: 'supplier_id', type: 'uuid' }) - supplierId: string; - - @Column({ name: 'supplier_name', length: 200 }) - supplierName: string; - - // Supplier document reference - @Column({ name: 'supplier_invoice_number', length: 50, nullable: true }) - supplierInvoiceNumber: string; - - @Column({ name: 'supplier_delivery_note', length: 50, nullable: true }) - supplierDeliveryNote: string; - - // Line counts - @Column({ name: 'lines_count', type: 'int', default: 0 }) - linesCount: number; - - @Column({ name: 'items_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) - itemsReceived: number; - - // Totals - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - // Currency - @Column({ name: 'currency_code', length: 3, default: 'MXN' }) - currencyCode: string; - - @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) - exchangeRate: number; - - // Quality check - @Column({ name: 'quality_checked', type: 'boolean', default: false }) - qualityChecked: boolean; - - @Column({ name: 'quality_checked_by', type: 'uuid', nullable: true }) - qualityCheckedBy: string; - - @Column({ name: 'quality_checked_at', type: 'timestamp with time zone', nullable: true }) - qualityCheckedAt: Date; - - @Column({ name: 'quality_notes', type: 'text', nullable: true }) - qualityNotes: string; - - // Discrepancies - @Column({ name: 'has_discrepancies', type: 'boolean', default: false }) - hasDiscrepancies: boolean; - - @Column({ name: 'discrepancy_notes', type: 'text', nullable: true }) - discrepancyNotes: string; - - // Receiver info - @Column({ name: 'received_by', type: 'uuid' }) - receivedBy: string; - - @Column({ name: 'receiver_name', length: 100, nullable: true }) - receiverName: string; - - // Delivery person info - @Column({ name: 'delivery_person', length: 100, nullable: true }) - deliveryPerson: string; - - @Column({ name: 'vehicle_plate', length: 20, nullable: true }) - vehiclePlate: string; - - // Posting - @Column({ name: 'posted_by', type: 'uuid', nullable: true }) - postedBy: string; - - @Column({ name: 'posted_at', type: 'timestamp with time zone', nullable: true }) - postedAt: Date; - - // Inventory impact - @Column({ name: 'stock_movements_created', type: 'boolean', default: false }) - stockMovementsCreated: boolean; - - // Cancellation - @Column({ name: 'cancelled_at', type: 'timestamp with time zone', nullable: true }) - cancelledAt: Date; - - @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) - cancelledBy: string; - - @Column({ name: 'cancellation_reason', length: 255, nullable: true }) - cancellationReason: string; - - // Attachments (delivery note photos, etc.) - @Column({ name: 'attachment_ids', type: 'jsonb', nullable: true }) - attachmentIds: string[]; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - // Relations - @OneToMany(() => GoodsReceiptLine, (line) => line.receipt) - lines: GoodsReceiptLine[]; -} diff --git a/backend/src/modules/purchases/entities/index.ts b/backend/src/modules/purchases/entities/index.ts deleted file mode 100644 index fdf8b19..0000000 --- a/backend/src/modules/purchases/entities/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './purchase-suggestion.entity'; -export * from './supplier-order.entity'; -export * from './supplier-order-line.entity'; -export * from './goods-receipt.entity'; -export * from './goods-receipt-line.entity'; diff --git a/backend/src/modules/purchases/entities/purchase-suggestion.entity.ts b/backend/src/modules/purchases/entities/purchase-suggestion.entity.ts deleted file mode 100644 index 6d37e2d..0000000 --- a/backend/src/modules/purchases/entities/purchase-suggestion.entity.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index, -} from 'typeorm'; - -export enum SuggestionStatus { - PENDING = 'pending', - REVIEWED = 'reviewed', - APPROVED = 'approved', - ORDERED = 'ordered', - REJECTED = 'rejected', - EXPIRED = 'expired', -} - -export enum SuggestionReason { - LOW_STOCK = 'low_stock', - OUT_OF_STOCK = 'out_of_stock', - REORDER_POINT = 'reorder_point', - SALES_FORECAST = 'sales_forecast', - SEASONAL = 'seasonal', - MANUAL = 'manual', -} - -@Entity('purchase_suggestions', { schema: 'retail' }) -@Index(['tenantId', 'branchId', 'status']) -@Index(['tenantId', 'productId']) -@Index(['tenantId', 'supplierId']) -@Index(['tenantId', 'createdAt']) -export class PurchaseSuggestion { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - @Column({ name: 'warehouse_id', type: 'uuid' }) - warehouseId: string; - - @Column({ - type: 'enum', - enum: SuggestionStatus, - default: SuggestionStatus.PENDING, - }) - status: SuggestionStatus; - - @Column({ - type: 'enum', - enum: SuggestionReason, - default: SuggestionReason.LOW_STOCK, - }) - reason: SuggestionReason; - - // Product (from erp-core) - @Column({ name: 'product_id', type: 'uuid' }) - productId: string; - - @Column({ name: 'product_code', length: 50 }) - productCode: string; - - @Column({ name: 'product_name', length: 200 }) - productName: string; - - // Supplier (from erp-core partners) - @Column({ name: 'supplier_id', type: 'uuid', nullable: true }) - supplierId: string; - - @Column({ name: 'supplier_name', length: 200, nullable: true }) - supplierName: string; - - // Stock levels - @Column({ name: 'current_stock', type: 'decimal', precision: 15, scale: 4, default: 0 }) - currentStock: number; - - @Column({ name: 'min_stock', type: 'decimal', precision: 15, scale: 4, default: 0 }) - minStock: number; - - @Column({ name: 'max_stock', type: 'decimal', precision: 15, scale: 4, nullable: true }) - maxStock: number; - - @Column({ name: 'reorder_point', type: 'decimal', precision: 15, scale: 4, default: 0 }) - reorderPoint: number; - - @Column({ name: 'safety_stock', type: 'decimal', precision: 15, scale: 4, default: 0 }) - safetyStock: number; - - // Suggested quantities - @Column({ name: 'suggested_quantity', type: 'decimal', precision: 15, scale: 4 }) - suggestedQuantity: number; - - @Column({ name: 'approved_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) - approvedQuantity: number; - - @Column({ name: 'ordered_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 }) - orderedQuantity: number; - - // UOM - @Column({ name: 'uom_id', type: 'uuid', nullable: true }) - uomId: string; - - @Column({ name: 'uom_name', length: 20, default: 'PZA' }) - uomName: string; - - // Pricing - @Column({ name: 'estimated_unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) - estimatedUnitCost: number; - - @Column({ name: 'estimated_total_cost', type: 'decimal', precision: 15, scale: 2, nullable: true }) - estimatedTotalCost: number; - - // Sales data (for forecast-based suggestions) - @Column({ name: 'avg_daily_sales', type: 'decimal', precision: 15, scale: 4, nullable: true }) - avgDailySales: number; - - @Column({ name: 'days_of_stock', type: 'int', nullable: true }) - daysOfStock: number; - - @Column({ name: 'lead_time_days', type: 'int', default: 0 }) - leadTimeDays: number; - - // Urgency - @Column({ type: 'int', default: 0 }) - priority: number; // 0=low, 1=medium, 2=high, 3=critical - - @Column({ name: 'due_date', type: 'date', nullable: true }) - dueDate: Date; - - // Generated order reference - @Column({ name: 'supplier_order_id', type: 'uuid', nullable: true }) - supplierOrderId: string; - - @Column({ name: 'supplier_order_number', length: 30, nullable: true }) - supplierOrderNumber: string; - - // Review info - @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) - reviewedBy: string; - - @Column({ name: 'reviewed_at', type: 'timestamp with time zone', nullable: true }) - reviewedAt: Date; - - @Column({ name: 'review_notes', type: 'text', nullable: true }) - reviewNotes: string; - - // Algorithm data - @Column({ name: 'calculation_data', type: 'jsonb', nullable: true }) - calculationData: { - algorithm: string; - salesPeriod: number; - salesTotal: number; - seasonalFactor?: number; - trendFactor?: number; - }; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Column({ name: 'expires_at', type: 'timestamp with time zone', nullable: true }) - expiresAt: Date; -} diff --git a/backend/src/modules/purchases/entities/supplier-order-line.entity.ts b/backend/src/modules/purchases/entities/supplier-order-line.entity.ts deleted file mode 100644 index 8ebf092..0000000 --- a/backend/src/modules/purchases/entities/supplier-order-line.entity.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { SupplierOrder } from './supplier-order.entity'; - -export enum OrderLineStatus { - PENDING = 'pending', - PARTIALLY_RECEIVED = 'partially_received', - RECEIVED = 'received', - CANCELLED = 'cancelled', -} - -@Entity('supplier_order_lines', { schema: 'retail' }) -@Index(['tenantId', 'orderId']) -@Index(['tenantId', 'productId']) -export class SupplierOrderLine { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'order_id', type: 'uuid' }) - orderId: string; - - @Column({ name: 'line_number', type: 'int' }) - lineNumber: number; - - @Column({ - type: 'enum', - enum: OrderLineStatus, - default: OrderLineStatus.PENDING, - }) - status: OrderLineStatus; - - // Product (from erp-core) - @Column({ name: 'product_id', type: 'uuid' }) - productId: string; - - @Column({ name: 'product_code', length: 50 }) - productCode: string; - - @Column({ name: 'product_name', length: 200 }) - productName: string; - - @Column({ name: 'supplier_product_code', length: 50, nullable: true }) - supplierProductCode: string; - - // Variant - @Column({ name: 'variant_id', type: 'uuid', nullable: true }) - variantId: string; - - @Column({ name: 'variant_name', length: 200, nullable: true }) - variantName: string; - - // UOM - @Column({ name: 'uom_id', type: 'uuid', nullable: true }) - uomId: string; - - @Column({ name: 'uom_name', length: 20, default: 'PZA' }) - uomName: string; - - // Quantities - @Column({ name: 'quantity_ordered', type: 'decimal', precision: 15, scale: 4 }) - quantityOrdered: number; - - @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) - quantityReceived: number; - - @Column({ name: 'quantity_pending', type: 'decimal', precision: 15, scale: 4 }) - quantityPending: number; - - @Column({ name: 'quantity_cancelled', type: 'decimal', precision: 15, scale: 4, default: 0 }) - quantityCancelled: number; - - // Pricing - @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 }) - unitPrice: number; - - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) - discountPercent: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - discountAmount: number; - - // Tax - @Column({ name: 'tax_id', type: 'uuid', nullable: true }) - taxId: string; - - @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) - taxRate: number; - - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; - - // Totals - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - // Received value - @Column({ name: 'received_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - receivedValue: number; - - // Last receipt - @Column({ name: 'last_receipt_date', type: 'date', nullable: true }) - lastReceiptDate: number; - - @Column({ name: 'last_receipt_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) - lastReceiptQuantity: number; - - // Source suggestion - @Column({ name: 'suggestion_id', type: 'uuid', nullable: true }) - suggestionId: string; - - // Expected delivery - @Column({ name: 'expected_date', type: 'date', nullable: true }) - expectedDate: Date; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - // Relations - @ManyToOne(() => SupplierOrder, (order) => order.lines) - @JoinColumn({ name: 'order_id' }) - order: SupplierOrder; -} diff --git a/backend/src/modules/purchases/entities/supplier-order.entity.ts b/backend/src/modules/purchases/entities/supplier-order.entity.ts deleted file mode 100644 index d30625e..0000000 --- a/backend/src/modules/purchases/entities/supplier-order.entity.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - Index, -} from 'typeorm'; -import { SupplierOrderLine } from './supplier-order-line.entity'; - -export enum SupplierOrderStatus { - DRAFT = 'draft', - PENDING_APPROVAL = 'pending_approval', - APPROVED = 'approved', - SENT = 'sent', - CONFIRMED = 'confirmed', - PARTIALLY_RECEIVED = 'partially_received', - RECEIVED = 'received', - CANCELLED = 'cancelled', -} - -@Entity('supplier_orders', { schema: 'retail' }) -@Index(['tenantId', 'branchId', 'status']) -@Index(['tenantId', 'supplierId']) -@Index(['tenantId', 'number'], { unique: true }) -@Index(['tenantId', 'orderDate']) -export class SupplierOrder { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId: string; - - @Column({ name: 'branch_id', type: 'uuid' }) - branchId: string; - - @Column({ name: 'warehouse_id', type: 'uuid' }) - warehouseId: string; - - @Column({ length: 30, unique: true }) - number: string; - - @Column({ - type: 'enum', - enum: SupplierOrderStatus, - default: SupplierOrderStatus.DRAFT, - }) - status: SupplierOrderStatus; - - // Supplier (from erp-core partners) - @Column({ name: 'supplier_id', type: 'uuid' }) - supplierId: string; - - @Column({ name: 'supplier_code', length: 50, nullable: true }) - supplierCode: string; - - @Column({ name: 'supplier_name', length: 200 }) - supplierName: string; - - @Column({ name: 'supplier_contact', length: 100, nullable: true }) - supplierContact: string; - - @Column({ name: 'supplier_email', length: 100, nullable: true }) - supplierEmail: string; - - @Column({ name: 'supplier_phone', length: 20, nullable: true }) - supplierPhone: string; - - // Dates - @Column({ name: 'order_date', type: 'date' }) - orderDate: Date; - - @Column({ name: 'expected_date', type: 'date', nullable: true }) - expectedDate: Date; - - @Column({ name: 'sent_at', type: 'timestamp with time zone', nullable: true }) - sentAt: Date; - - @Column({ name: 'confirmed_at', type: 'timestamp with time zone', nullable: true }) - confirmedAt: Date; - - // Totals - @Column({ name: 'lines_count', type: 'int', default: 0 }) - linesCount: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; - - @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) - discountPercent: number; - - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - discountAmount: number; - - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; - - @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - shippingAmount: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - // Currency - @Column({ name: 'currency_code', length: 3, default: 'MXN' }) - currencyCode: string; - - @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) - exchangeRate: number; - - // Payment terms - @Column({ name: 'payment_terms', length: 50, nullable: true }) - paymentTerms: string; - - @Column({ name: 'payment_due_date', type: 'date', nullable: true }) - paymentDueDate: Date; - - // Shipping - @Column({ name: 'shipping_method', length: 100, nullable: true }) - shippingMethod: string; - - @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) - shippingAddress: { - address1: string; - address2?: string; - city: string; - state: string; - postalCode: string; - country: string; - }; - - // Reference - @Column({ name: 'supplier_reference', length: 50, nullable: true }) - supplierReference: string; - - @Column({ name: 'quotation_reference', length: 50, nullable: true }) - quotationReference: string; - - // Users - @Column({ name: 'created_by', type: 'uuid' }) - createdBy: string; - - @Column({ name: 'approved_by', type: 'uuid', nullable: true }) - approvedBy: string; - - @Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true }) - approvedAt: Date; - - @Column({ name: 'sent_by', type: 'uuid', nullable: true }) - sentBy: string; - - // Communication - @Column({ name: 'email_sent', type: 'boolean', default: false }) - emailSent: boolean; - - @Column({ name: 'email_sent_at', type: 'timestamp with time zone', nullable: true }) - emailSentAt: Date; - - @Column({ name: 'email_sent_to', length: 100, nullable: true }) - emailSentTo: string; - - // Receipt tracking - @Column({ name: 'total_received_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) - totalReceivedValue: number; - - @Column({ name: 'last_receipt_date', type: 'date', nullable: true }) - lastReceiptDate: Date; - - // Cancellation - @Column({ name: 'cancelled_at', type: 'timestamp with time zone', nullable: true }) - cancelledAt: Date; - - @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) - cancelledBy: string; - - @Column({ name: 'cancellation_reason', length: 255, nullable: true }) - cancellationReason: string; - - // Notes - @Column({ type: 'text', nullable: true }) - notes: string; - - @Column({ name: 'internal_notes', type: 'text', nullable: true }) - internalNotes: string; - - // Metadata - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - // Relations - @OneToMany(() => SupplierOrderLine, (line) => line.order) - lines: SupplierOrderLine[]; -} diff --git a/backend/src/modules/purchases/index.ts b/backend/src/modules/purchases/index.ts deleted file mode 100644 index 0588326..0000000 --- a/backend/src/modules/purchases/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './entities'; -export * from './services'; -export * from './controllers'; -export * from './routes'; -export * from './validation'; diff --git a/backend/src/modules/purchases/routes/index.ts b/backend/src/modules/purchases/routes/index.ts deleted file mode 100644 index 178850f..0000000 --- a/backend/src/modules/purchases/routes/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as purchasesRoutes } from './purchases.routes'; diff --git a/backend/src/modules/purchases/routes/purchases.routes.ts b/backend/src/modules/purchases/routes/purchases.routes.ts deleted file mode 100644 index e905fcd..0000000 --- a/backend/src/modules/purchases/routes/purchases.routes.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { Router } from 'express'; -import { purchasesController } from '../controllers/purchases.controller'; -import { authMiddleware, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware'; -import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware'; -import { branchMiddleware, validateBranchMiddleware, requireBranchCapability } from '../../../shared/middleware/branch.middleware'; -import { AuthenticatedRequest } from '../../../shared/types'; - -const router = Router(); - -// All routes require tenant and authentication -router.use(tenantMiddleware); -router.use(authMiddleware); - -// ==================== SUPPLIER ORDER ROUTES ==================== - -// Routes that require branch context -router.use('/orders', branchMiddleware); -router.use('/orders', validateBranchMiddleware); - -// Create supplier order -router.post( - '/orders', - requirePermissions('purchases:create'), - (req, res, next) => purchasesController.createOrder(req as AuthenticatedRequest, res, next) -); - -// List supplier orders -router.get( - '/orders', - requirePermissions('purchases:read'), - (req, res, next) => purchasesController.listOrders(req as AuthenticatedRequest, res, next) -); - -// Get order statistics -router.get( - '/orders/stats', - requirePermissions('purchases:read'), - (req, res, next) => purchasesController.getOrderStats(req as AuthenticatedRequest, res, next) -); - -// Get supplier order by ID -router.get( - '/orders/:id', - requirePermissions('purchases:read'), - (req, res, next) => purchasesController.getOrder(req as AuthenticatedRequest, res, next) -); - -// Update supplier order -router.put( - '/orders/:id', - requirePermissions('purchases:update'), - (req, res, next) => purchasesController.updateOrder(req as AuthenticatedRequest, res, next) -); - -// Submit order for approval -router.post( - '/orders/:id/submit', - requirePermissions('purchases:update'), - (req, res, next) => purchasesController.submitForApproval(req as AuthenticatedRequest, res, next) -); - -// Approve order (requires manager+ role) -router.post( - '/orders/:id/approve', - requireRoles('admin', 'manager'), - requirePermissions('purchases:approve'), - (req, res, next) => purchasesController.approveOrder(req as AuthenticatedRequest, res, next) -); - -// Reject order (requires manager+ role) -router.post( - '/orders/:id/reject', - requireRoles('admin', 'manager'), - requirePermissions('purchases:approve'), - (req, res, next) => purchasesController.rejectOrder(req as AuthenticatedRequest, res, next) -); - -// Send order to supplier -router.post( - '/orders/:id/send', - requirePermissions('purchases:update'), - (req, res, next) => purchasesController.sendToSupplier(req as AuthenticatedRequest, res, next) -); - -// Supplier confirmation -router.post( - '/orders/:id/confirm', - requirePermissions('purchases:update'), - (req, res, next) => purchasesController.supplierConfirmation(req as AuthenticatedRequest, res, next) -); - -// Cancel order (requires supervisor+ role) -router.post( - '/orders/:id/cancel', - requireRoles('admin', 'manager', 'supervisor'), - requirePermissions('purchases:cancel'), - (req, res, next) => purchasesController.cancelOrder(req as AuthenticatedRequest, res, next) -); - -// ==================== GOODS RECEIPT ROUTES ==================== - -// Routes that require branch context -router.use('/receipts', branchMiddleware); -router.use('/receipts', validateBranchMiddleware); - -// Create goods receipt -router.post( - '/receipts', - requirePermissions('receipts:create'), - (req, res, next) => purchasesController.createReceipt(req as AuthenticatedRequest, res, next) -); - -// Create receipt from order -router.post( - '/receipts/from-order', - requirePermissions('receipts:create'), - (req, res, next) => purchasesController.createReceiptFromOrder(req as AuthenticatedRequest, res, next) -); - -// List goods receipts -router.get( - '/receipts', - requirePermissions('receipts:read'), - (req, res, next) => purchasesController.listReceipts(req as AuthenticatedRequest, res, next) -); - -// Get receipt statistics -router.get( - '/receipts/stats', - requirePermissions('receipts:read'), - (req, res, next) => purchasesController.getReceiptStats(req as AuthenticatedRequest, res, next) -); - -// Get goods receipt by ID -router.get( - '/receipts/:id', - requirePermissions('receipts:read'), - (req, res, next) => purchasesController.getReceipt(req as AuthenticatedRequest, res, next) -); - -// Update goods receipt -router.put( - '/receipts/:id', - requirePermissions('receipts:update'), - (req, res, next) => purchasesController.updateReceipt(req as AuthenticatedRequest, res, next) -); - -// Complete quality check -router.post( - '/receipts/:id/quality-check', - requirePermissions('receipts:quality'), - (req, res, next) => purchasesController.completeQualityCheck(req as AuthenticatedRequest, res, next) -); - -// Post receipt -router.post( - '/receipts/:id/post', - requireRoles('admin', 'manager', 'supervisor'), - requirePermissions('receipts:post'), - (req, res, next) => purchasesController.postReceipt(req as AuthenticatedRequest, res, next) -); - -// Cancel receipt -router.post( - '/receipts/:id/cancel', - requireRoles('admin', 'manager', 'supervisor'), - requirePermissions('receipts:cancel'), - (req, res, next) => purchasesController.cancelReceipt(req as AuthenticatedRequest, res, next) -); - -// ==================== PURCHASE SUGGESTION ROUTES ==================== - -// Routes that require branch context -router.use('/suggestions', branchMiddleware); -router.use('/suggestions', validateBranchMiddleware); - -// Create suggestion -router.post( - '/suggestions', - requirePermissions('suggestions:create'), - (req, res, next) => purchasesController.createSuggestion(req as AuthenticatedRequest, res, next) -); - -// Generate suggestions -router.post( - '/suggestions/generate', - requireRoles('admin', 'manager'), - requirePermissions('suggestions:generate'), - (req, res, next) => purchasesController.generateSuggestions(req as AuthenticatedRequest, res, next) -); - -// Bulk review suggestions -router.post( - '/suggestions/bulk-review', - requireRoles('admin', 'manager'), - requirePermissions('suggestions:approve'), - (req, res, next) => purchasesController.bulkReviewSuggestions(req as AuthenticatedRequest, res, next) -); - -// Convert suggestions to orders -router.post( - '/suggestions/convert', - requireRoles('admin', 'manager'), - requirePermissions('suggestions:convert'), - (req, res, next) => purchasesController.convertToOrders(req as AuthenticatedRequest, res, next) -); - -// List suggestions -router.get( - '/suggestions', - requirePermissions('suggestions:read'), - (req, res, next) => purchasesController.listSuggestions(req as AuthenticatedRequest, res, next) -); - -// Get suggestion statistics -router.get( - '/suggestions/stats', - requirePermissions('suggestions:read'), - (req, res, next) => purchasesController.getSuggestionStats(req as AuthenticatedRequest, res, next) -); - -// Get suggestion by ID -router.get( - '/suggestions/:id', - requirePermissions('suggestions:read'), - (req, res, next) => purchasesController.getSuggestion(req as AuthenticatedRequest, res, next) -); - -// Update suggestion -router.put( - '/suggestions/:id', - requirePermissions('suggestions:update'), - (req, res, next) => purchasesController.updateSuggestion(req as AuthenticatedRequest, res, next) -); - -// Review suggestion -router.post( - '/suggestions/:id/review', - requireRoles('admin', 'manager'), - requirePermissions('suggestions:approve'), - (req, res, next) => purchasesController.reviewSuggestion(req as AuthenticatedRequest, res, next) -); - -// Delete suggestion -router.delete( - '/suggestions/:id', - requireRoles('admin', 'manager'), - requirePermissions('suggestions:delete'), - (req, res, next) => purchasesController.deleteSuggestion(req as AuthenticatedRequest, res, next) -); - -export default router; diff --git a/backend/src/modules/purchases/services/goods-receipt.service.ts b/backend/src/modules/purchases/services/goods-receipt.service.ts deleted file mode 100644 index 0458028..0000000 --- a/backend/src/modules/purchases/services/goods-receipt.service.ts +++ /dev/null @@ -1,700 +0,0 @@ -import { Repository, DataSource, In } from 'typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult, PaginatedResult } from '../../../shared/types'; -import { - GoodsReceipt, - ReceiptStatus, - GoodsReceiptLine, - ReceiptLineStatus, - QualityStatus, - SupplierOrder, - SupplierOrderLine, -} from '../entities'; -import { - CreateGoodsReceiptInput, - UpdateGoodsReceiptInput, - CreateReceiptFromOrderInput, -} from '../validation/purchases.schema'; -import { SupplierOrderService } from './supplier-order.service'; - -export class GoodsReceiptService extends BaseService { - private lineRepository: Repository; - private orderRepository: Repository; - private orderLineRepository: Repository; - - constructor( - private dataSource: DataSource, - private supplierOrderService: SupplierOrderService - ) { - super(dataSource.getRepository(GoodsReceipt)); - this.lineRepository = dataSource.getRepository(GoodsReceiptLine); - this.orderRepository = dataSource.getRepository(SupplierOrder); - this.orderLineRepository = dataSource.getRepository(SupplierOrderLine); - } - - /** - * Generate receipt number - */ - private async generateReceiptNumber(tenantId: string): Promise { - const date = new Date(); - const year = date.getFullYear().toString().slice(-2); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - - const count = await this.repository.count({ - where: { tenantId }, - }); - - const sequence = (count + 1).toString().padStart(5, '0'); - return `GR-${year}${month}-${sequence}`; - } - - /** - * Calculate receipt totals - */ - private calculateTotals(lines: GoodsReceiptLine[]): { - subtotal: number; - taxAmount: number; - total: number; - itemsReceived: number; - } { - let subtotal = 0; - let taxAmount = 0; - let itemsReceived = 0; - - for (const line of lines) { - const lineSubtotal = line.quantityAccepted * line.unitCost; - const lineTax = lineSubtotal * line.taxRate; - - subtotal += lineSubtotal; - taxAmount += lineTax; - itemsReceived += line.quantityAccepted; - } - - return { - subtotal: Math.round(subtotal * 100) / 100, - taxAmount: Math.round(taxAmount * 100) / 100, - total: Math.round((subtotal + taxAmount) * 100) / 100, - itemsReceived: Math.round(itemsReceived * 10000) / 10000, - }; - } - - /** - * Create goods receipt (standalone, without order) - */ - async createReceipt( - tenantId: string, - data: CreateGoodsReceiptInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const number = await this.generateReceiptNumber(tenantId); - - // Create receipt lines - const lines: GoodsReceiptLine[] = data.lines.map((lineData, index) => { - const quantityAccepted = lineData.quantityReceived - (lineData.quantityRejected || 0); - const lineSubtotal = quantityAccepted * lineData.unitCost; - const lineTax = lineSubtotal * (lineData.taxRate || 0.16); - - return queryRunner.manager.create(GoodsReceiptLine, { - tenantId, - lineNumber: index + 1, - status: ReceiptLineStatus.RECEIVED, - orderLineId: lineData.orderLineId, - productId: lineData.productId, - productCode: lineData.productCode, - productName: lineData.productName, - variantId: lineData.variantId, - variantName: lineData.variantName, - uomId: lineData.uomId, - uomName: lineData.uomName || 'PZA', - quantityExpected: lineData.quantityExpected, - quantityReceived: lineData.quantityReceived, - quantityRejected: lineData.quantityRejected || 0, - quantityAccepted, - unitCost: lineData.unitCost, - taxRate: lineData.taxRate || 0.16, - taxAmount: lineTax, - subtotal: lineSubtotal, - total: lineSubtotal + lineTax, - locationId: lineData.locationId, - locationCode: lineData.locationCode, - lotNumber: lineData.lotNumber, - serialNumbers: lineData.serialNumbers, - expiryDate: lineData.expiryDate, - manufactureDate: lineData.manufactureDate, - qualityStatus: lineData.qualityStatus || QualityStatus.NOT_CHECKED, - qualityNotes: lineData.qualityNotes, - rejectionReason: lineData.rejectionReason, - hasDiscrepancy: lineData.quantityRejected ? lineData.quantityRejected > 0 : false, - notes: lineData.notes, - }); - }); - - const totals = this.calculateTotals(lines); - - // Create receipt - const receipt = queryRunner.manager.create(GoodsReceipt, { - tenantId, - branchId: data.branchId, - warehouseId: data.warehouseId, - number, - status: ReceiptStatus.DRAFT, - receiptDate: data.receiptDate, - supplierOrderId: data.supplierOrderId, - supplierId: data.supplierId, - supplierName: data.supplierName, - supplierInvoiceNumber: data.supplierInvoiceNumber, - supplierDeliveryNote: data.supplierDeliveryNote, - linesCount: lines.length, - ...totals, - currencyCode: data.currencyCode || 'MXN', - exchangeRate: data.exchangeRate || 1, - deliveryPerson: data.deliveryPerson, - vehiclePlate: data.vehiclePlate, - receivedBy: userId, - hasDiscrepancies: lines.some(l => l.hasDiscrepancy), - notes: data.notes, - }); - - const savedReceipt = await queryRunner.manager.save(receipt); - - // Save lines with receipt reference - for (const line of lines) { - line.receiptId = savedReceipt.id; - } - await queryRunner.manager.save(lines); - - await queryRunner.commitTransaction(); - - const result = await this.findById(tenantId, savedReceipt.id, ['lines']); - return { success: true, data: result! }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_RECEIPT_FAILED', - message: error.message || 'Failed to create goods receipt', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Create goods receipt from supplier order - */ - async createReceiptFromOrder( - tenantId: string, - data: CreateReceiptFromOrderInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - // Get the order with lines - const order = await this.orderRepository.findOne({ - where: { tenantId, id: data.supplierOrderId }, - relations: ['lines'], - }); - - if (!order) { - return { - success: false, - error: { code: 'ORDER_NOT_FOUND', message: 'Supplier order not found' }, - }; - } - - const number = await this.generateReceiptNumber(tenantId); - - // Create receipt lines from order lines - const lines: GoodsReceiptLine[] = []; - let lineNumber = 1; - - for (const lineData of data.lines) { - const orderLine = order.lines.find(l => l.id === lineData.orderLineId); - if (!orderLine) continue; - - const quantityAccepted = lineData.quantityReceived - (lineData.quantityRejected || 0); - const lineSubtotal = quantityAccepted * orderLine.unitPrice; - const lineTax = lineSubtotal * orderLine.taxRate; - - const receiptLine = queryRunner.manager.create(GoodsReceiptLine, { - tenantId, - lineNumber: lineNumber++, - status: ReceiptLineStatus.RECEIVED, - orderLineId: orderLine.id, - productId: orderLine.productId, - productCode: orderLine.productCode, - productName: orderLine.productName, - variantId: orderLine.variantId, - variantName: orderLine.variantName, - uomId: orderLine.uomId, - uomName: orderLine.uomName, - quantityExpected: orderLine.quantityPending, - quantityReceived: lineData.quantityReceived, - quantityRejected: lineData.quantityRejected || 0, - quantityAccepted, - unitCost: orderLine.unitPrice, - taxRate: orderLine.taxRate, - taxAmount: lineTax, - subtotal: lineSubtotal, - total: lineSubtotal + lineTax, - locationId: lineData.locationId, - lotNumber: lineData.lotNumber, - serialNumbers: lineData.serialNumbers, - expiryDate: lineData.expiryDate, - qualityStatus: lineData.qualityStatus || QualityStatus.NOT_CHECKED, - qualityNotes: lineData.qualityNotes, - rejectionReason: lineData.rejectionReason, - hasDiscrepancy: (lineData.quantityRejected || 0) > 0 || - lineData.quantityReceived !== orderLine.quantityPending, - notes: lineData.notes, - }); - - lines.push(receiptLine); - } - - if (lines.length === 0) { - return { - success: false, - error: { code: 'NO_LINES', message: 'No valid lines to receive' }, - }; - } - - const totals = this.calculateTotals(lines); - - // Create receipt - const receipt = queryRunner.manager.create(GoodsReceipt, { - tenantId, - branchId: order.branchId, - warehouseId: order.warehouseId, - number, - status: ReceiptStatus.DRAFT, - receiptDate: data.receiptDate, - supplierOrderId: order.id, - supplierOrderNumber: order.number, - supplierId: order.supplierId, - supplierName: order.supplierName, - supplierInvoiceNumber: data.supplierInvoiceNumber, - supplierDeliveryNote: data.supplierDeliveryNote, - linesCount: lines.length, - ...totals, - currencyCode: order.currencyCode, - exchangeRate: order.exchangeRate, - deliveryPerson: data.deliveryPerson, - vehiclePlate: data.vehiclePlate, - receivedBy: userId, - hasDiscrepancies: lines.some(l => l.hasDiscrepancy), - notes: data.notes, - }); - - const savedReceipt = await queryRunner.manager.save(receipt); - - // Save lines with receipt reference - for (const line of lines) { - line.receiptId = savedReceipt.id; - } - await queryRunner.manager.save(lines); - - await queryRunner.commitTransaction(); - - const result = await this.findById(tenantId, savedReceipt.id, ['lines']); - return { success: true, data: result! }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_RECEIPT_FAILED', - message: error.message || 'Failed to create goods receipt from order', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Update goods receipt - */ - async updateReceipt( - tenantId: string, - receiptId: string, - data: UpdateGoodsReceiptInput, - userId: string - ): Promise> { - const receipt = await this.findById(tenantId, receiptId, ['lines']); - if (!receipt) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Receipt not found' }, - }; - } - - if (receipt.status !== ReceiptStatus.DRAFT && receipt.status !== ReceiptStatus.IN_PROGRESS) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Only draft or in-progress receipts can be updated' }, - }; - } - - // Update receipt fields (excluding lines for simplicity) - const { lines: _, ...updateData } = data; - Object.assign(receipt, updateData); - - const saved = await this.repository.save(receipt); - return { success: true, data: saved }; - } - - /** - * Complete quality check - */ - async completeQualityCheck( - tenantId: string, - receiptId: string, - userId: string, - passed: boolean, - notes?: string, - lineChecks?: { lineId: string; qualityStatus: QualityStatus; notes?: string; rejectionReason?: string }[] - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const receipt = await this.findById(tenantId, receiptId, ['lines']); - if (!receipt) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Receipt not found' }, - }; - } - - // Update line quality statuses - if (lineChecks) { - for (const check of lineChecks) { - const line = receipt.lines.find(l => l.id === check.lineId); - if (line) { - line.qualityStatus = check.qualityStatus; - if (check.notes) { - line.qualityNotes = check.notes; - } - if (check.rejectionReason) { - line.rejectionReason = check.rejectionReason; - } - } - } - await queryRunner.manager.save(receipt.lines); - } - - // Update receipt quality check info - receipt.qualityChecked = true; - receipt.qualityCheckedBy = userId; - receipt.qualityCheckedAt = new Date(); - if (notes) { - receipt.qualityNotes = notes; - } - - // Update status based on quality check result - if (!passed) { - receipt.hasDiscrepancies = true; - receipt.discrepancyNotes = notes || 'Quality check failed'; - } - - const saved = await queryRunner.manager.save(receipt); - await queryRunner.commitTransaction(); - - return { success: true, data: saved }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'QUALITY_CHECK_FAILED', - message: error.message || 'Failed to complete quality check', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Post receipt (finalize and update inventory) - */ - async postReceipt( - tenantId: string, - receiptId: string, - userId: string, - notes?: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const receipt = await this.findById(tenantId, receiptId, ['lines']); - if (!receipt) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Receipt not found' }, - }; - } - - if (receipt.status !== ReceiptStatus.DRAFT && receipt.status !== ReceiptStatus.COMPLETED) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Receipt must be in draft or completed status to post' }, - }; - } - - // Update supplier order if linked - if (receipt.supplierOrderId) { - const receivedLines = receipt.lines - .filter(l => l.orderLineId) - .map(l => ({ - lineId: l.orderLineId!, - quantityReceived: l.quantityAccepted, - value: l.total, - })); - - if (receivedLines.length > 0) { - await this.supplierOrderService.updateOrderFromReceipt( - tenantId, - receipt.supplierOrderId, - receivedLines - ); - } - } - - // TODO: Create stock movements for inventory update - // This would integrate with the inventory module - receipt.stockMovementsCreated = true; - - receipt.status = ReceiptStatus.POSTED; - receipt.postedBy = userId; - receipt.postedAt = new Date(); - if (notes) { - receipt.notes = receipt.notes - ? `${receipt.notes}\n[Posted] ${notes}` - : `[Posted] ${notes}`; - } - - const saved = await queryRunner.manager.save(receipt); - await queryRunner.commitTransaction(); - - return { success: true, data: saved }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'POST_RECEIPT_FAILED', - message: error.message || 'Failed to post receipt', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Cancel receipt - */ - async cancelReceipt( - tenantId: string, - receiptId: string, - userId: string, - reason: string - ): Promise> { - const receipt = await this.findById(tenantId, receiptId); - if (!receipt) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Receipt not found' }, - }; - } - - if (receipt.status === ReceiptStatus.POSTED) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Posted receipts cannot be cancelled' }, - }; - } - - receipt.status = ReceiptStatus.CANCELLED; - receipt.cancelledAt = new Date(); - receipt.cancelledBy = userId; - receipt.cancellationReason = reason; - - const saved = await this.repository.save(receipt); - return { success: true, data: saved }; - } - - /** - * List goods receipts with filters - */ - async listReceipts( - tenantId: string, - branchId: string, - query: { - page?: number; - limit?: number; - status?: ReceiptStatus; - supplierId?: string; - supplierOrderId?: string; - startDate?: Date; - endDate?: Date; - search?: string; - hasDiscrepancies?: boolean; - } - ): Promise> { - const { - page = 1, - limit = 20, - status, - supplierId, - supplierOrderId, - startDate, - endDate, - search, - hasDiscrepancies, - } = query; - - const qb = this.repository.createQueryBuilder('receipt') - .where('receipt.tenantId = :tenantId', { tenantId }) - .andWhere('receipt.branchId = :branchId', { branchId }); - - if (status) { - qb.andWhere('receipt.status = :status', { status }); - } - - if (supplierId) { - qb.andWhere('receipt.supplierId = :supplierId', { supplierId }); - } - - if (supplierOrderId) { - qb.andWhere('receipt.supplierOrderId = :supplierOrderId', { supplierOrderId }); - } - - if (startDate) { - qb.andWhere('receipt.receiptDate >= :startDate', { startDate }); - } - - if (endDate) { - qb.andWhere('receipt.receiptDate <= :endDate', { endDate }); - } - - if (search) { - qb.andWhere( - '(receipt.number ILIKE :search OR receipt.supplierName ILIKE :search OR receipt.supplierInvoiceNumber ILIKE :search)', - { search: `%${search}%` } - ); - } - - if (hasDiscrepancies !== undefined) { - qb.andWhere('receipt.hasDiscrepancies = :hasDiscrepancies', { hasDiscrepancies }); - } - - const total = await qb.getCount(); - - qb.orderBy('receipt.createdAt', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const data = await qb.getMany(); - const totalPages = Math.ceil(total / limit); - - return { - data, - pagination: { - page, - limit, - total, - totalPages, - hasNext: page < totalPages, - hasPrev: page > 1, - }, - }; - } - - /** - * Get receipts pending for an order - */ - async getReceiptsForOrder( - tenantId: string, - orderId: string - ): Promise { - return this.repository.find({ - where: { tenantId, supplierOrderId: orderId }, - relations: ['lines'], - order: { creiptDate: 'DESC' }, - }); - } - - /** - * Get receipt statistics - */ - async getReceiptStats( - tenantId: string, - branchId: string, - startDate: Date, - endDate: Date - ): Promise<{ - totalReceipts: number; - totalValue: number; - withDiscrepancies: number; - byStatus: Record; - avgProcessingTime: number; - }> { - const receipts = await this.repository.find({ - where: { tenantId, branchId }, - }); - - // Filter by date range - const filteredReceipts = receipts.filter(r => { - const receiptDate = new Date(r.receiptDate); - return receiptDate >= startDate && receiptDate <= endDate; - }); - - const totalValue = filteredReceipts.reduce((sum, r) => sum + Number(r.total), 0); - const withDiscrepancies = filteredReceipts.filter(r => r.hasDiscrepancies).length; - - // Group by status - const byStatus: Record = {}; - for (const receipt of filteredReceipts) { - byStatus[receipt.status] = (byStatus[receipt.status] || 0) + 1; - } - - // Calculate average processing time (from creation to posting) - const postedReceipts = filteredReceipts.filter(r => r.postedAt); - let totalProcessingTime = 0; - for (const receipt of postedReceipts) { - const processingTime = new Date(receipt.postedAt!).getTime() - new Date(receipt.createdAt).getTime(); - totalProcessingTime += processingTime; - } - const avgProcessingTime = postedReceipts.length > 0 - ? totalProcessingTime / postedReceipts.length / (1000 * 60 * 60) // Convert to hours - : 0; - - return { - totalReceipts: filteredReceipts.length, - totalValue: Math.round(totalValue * 100) / 100, - withDiscrepancies, - byStatus, - avgProcessingTime: Math.round(avgProcessingTime * 10) / 10, - }; - } -} diff --git a/backend/src/modules/purchases/services/index.ts b/backend/src/modules/purchases/services/index.ts deleted file mode 100644 index 4286cd7..0000000 --- a/backend/src/modules/purchases/services/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './supplier-order.service'; -export * from './goods-receipt.service'; -export * from './purchase-suggestion.service'; diff --git a/backend/src/modules/purchases/services/purchase-suggestion.service.ts b/backend/src/modules/purchases/services/purchase-suggestion.service.ts deleted file mode 100644 index 9d741bb..0000000 --- a/backend/src/modules/purchases/services/purchase-suggestion.service.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { Repository, DataSource, In } from 'typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult, PaginatedResult } from '../../../shared/types'; -import { - PurchaseSuggestion, - SuggestionStatus, - SuggestionReason, - SupplierOrder, - SupplierOrderLine, -} from '../entities'; -import { - CreateSuggestionInput, - UpdateSuggestionInput, - ReviewSuggestionInput, - ConvertSuggestionsInput, - GenerateSuggestionsInput, -} from '../validation/purchases.schema'; -import { SupplierOrderService } from './supplier-order.service'; - -export class PurchaseSuggestionService extends BaseService { - constructor( - private dataSource: DataSource, - private supplierOrderService: SupplierOrderService - ) { - super(dataSource.getRepository(PurchaseSuggestion)); - } - - /** - * Create manual suggestion - */ - async createSuggestion( - tenantId: string, - data: CreateSuggestionInput, - userId: string - ): Promise> { - try { - const suggestion = this.repository.create({ - tenantId, - branchId: data.branchId, - warehouseId: data.warehouseId, - status: SuggestionStatus.PENDING, - reason: data.reason || SuggestionReason.MANUAL, - productId: data.productId, - productCode: data.productCode, - productName: data.productName, - supplierId: data.supplierId, - supplierName: data.supplierName, - suggestedQuantity: data.suggestedQuantity, - estimatedUnitCost: data.estimatedUnitCost, - estimatedTotalCost: data.estimatedUnitCost - ? data.estimatedUnitCost * data.suggestedQuantity - : undefined, - priority: data.priority || 0, - dueDate: data.dueDate, - notes: data.notes, - calculationData: { - algorithm: 'manual', - salesPeriod: 0, - salesTotal: 0, - }, - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days - }); - - const saved = await this.repository.save(suggestion); - return { success: true, data: saved }; - } catch (error: any) { - return { - success: false, - error: { - code: 'CREATE_SUGGESTION_FAILED', - message: error.message || 'Failed to create suggestion', - details: error, - }, - }; - } - } - - /** - * Update suggestion - */ - async updateSuggestion( - tenantId: string, - suggestionId: string, - data: UpdateSuggestionInput, - userId: string - ): Promise> { - const suggestion = await this.findById(tenantId, suggestionId); - if (!suggestion) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Suggestion not found' }, - }; - } - - if (suggestion.status !== SuggestionStatus.PENDING) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Only pending suggestions can be updated' }, - }; - } - - Object.assign(suggestion, data); - - // Recalculate estimated total if unit cost or quantity changed - if (data.estimatedUnitCost !== undefined || data.suggestedQuantity !== undefined) { - const unitCost = data.estimatedUnitCost ?? suggestion.estimatedUnitCost; - const quantity = data.suggestedQuantity ?? suggestion.suggestedQuantity; - if (unitCost) { - suggestion.estimatedTotalCost = unitCost * quantity; - } - } - - const saved = await this.repository.save(suggestion); - return { success: true, data: saved }; - } - - /** - * Review suggestion (approve, reject, or modify) - */ - async reviewSuggestion( - tenantId: string, - suggestionId: string, - userId: string, - review: ReviewSuggestionInput - ): Promise> { - const suggestion = await this.findById(tenantId, suggestionId); - if (!suggestion) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Suggestion not found' }, - }; - } - - if (suggestion.status !== SuggestionStatus.PENDING && suggestion.status !== SuggestionStatus.REVIEWED) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Suggestion cannot be reviewed in current status' }, - }; - } - - suggestion.reviewedBy = userId; - suggestion.reviewedAt = new Date(); - if (review.notes) { - suggestion.reviewNotes = review.notes; - } - - switch (review.action) { - case 'approve': - suggestion.status = SuggestionStatus.APPROVED; - suggestion.approvedQuantity = review.approvedQuantity ?? suggestion.suggestedQuantity; - if (review.supplierId) { - suggestion.supplierId = review.supplierId; - } - break; - - case 'reject': - suggestion.status = SuggestionStatus.REJECTED; - break; - - case 'modify': - suggestion.status = SuggestionStatus.REVIEWED; - if (review.approvedQuantity) { - suggestion.approvedQuantity = review.approvedQuantity; - } - if (review.supplierId) { - suggestion.supplierId = review.supplierId; - } - break; - } - - const saved = await this.repository.save(suggestion); - return { success: true, data: saved }; - } - - /** - * Bulk review suggestions - */ - async bulkReviewSuggestions( - tenantId: string, - suggestionIds: string[], - userId: string, - action: 'approve' | 'reject', - notes?: string - ): Promise> { - const suggestions = await this.repository.find({ - where: { - tenantId, - id: In(suggestionIds), - status: In([SuggestionStatus.PENDING, SuggestionStatus.REVIEWED]), - }, - }); - - const failed: string[] = []; - let processed = 0; - - for (const suggestion of suggestions) { - try { - suggestion.reviewedBy = userId; - suggestion.reviewedAt = new Date(); - suggestion.reviewNotes = notes; - - if (action === 'approve') { - suggestion.status = SuggestionStatus.APPROVED; - suggestion.approvedQuantity = suggestion.suggestedQuantity; - } else { - suggestion.status = SuggestionStatus.REJECTED; - } - - await this.repository.save(suggestion); - processed++; - } catch (error) { - failed.push(suggestion.id); - } - } - - // Add IDs that weren't found - const foundIds = suggestions.map(s => s.id); - for (const id of suggestionIds) { - if (!foundIds.includes(id) && !failed.includes(id)) { - failed.push(id); - } - } - - return { - success: true, - data: { processed, failed }, - }; - } - - /** - * Convert approved suggestions to supplier orders - */ - async convertToOrders( - tenantId: string, - data: ConvertSuggestionsInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - // Get approved suggestions - const suggestions = await this.repository.find({ - where: { - tenantId, - id: In(data.suggestionIds), - status: SuggestionStatus.APPROVED, - }, - }); - - if (suggestions.length === 0) { - return { - success: false, - error: { code: 'NO_SUGGESTIONS', message: 'No approved suggestions found' }, - }; - } - - const orders: SupplierOrder[] = []; - - if (data.groupBySupplier) { - // Group by supplier - const bySupplier = new Map(); - for (const suggestion of suggestions) { - const key = suggestion.supplierId || 'no_supplier'; - const existing = bySupplier.get(key) || []; - existing.push(suggestion); - bySupplier.set(key, existing); - } - - // Create one order per supplier - for (const [supplierId, supplierSuggestions] of bySupplier.entries()) { - if (supplierId === 'no_supplier') { - // Skip suggestions without supplier - continue; - } - - const firstSuggestion = supplierSuggestions[0]; - const lines = supplierSuggestions.map(s => ({ - productId: s.productId, - productCode: s.productCode, - productName: s.productName, - quantityOrdered: s.approvedQuantity || s.suggestedQuantity, - unitPrice: s.estimatedUnitCost || 0, - taxRate: 0.16, - })); - - const orderResult = await this.supplierOrderService.createOrder( - tenantId, - { - branchId: firstSuggestion.branchId, - warehouseId: firstSuggestion.warehouseId, - supplierId, - supplierName: firstSuggestion.supplierName || '', - orderDate: new Date(), - expectedDate: data.expectedDate, - lines, - }, - userId - ); - - if (orderResult.success && orderResult.data) { - orders.push(orderResult.data); - - // Update suggestions with order reference - for (const suggestion of supplierSuggestions) { - suggestion.status = SuggestionStatus.ORDERED; - suggestion.supplierOrderId = orderResult.data.id; - suggestion.supplierOrderNumber = orderResult.data.number; - suggestion.orderedQuantity = suggestion.approvedQuantity || suggestion.suggestedQuantity; - } - await queryRunner.manager.save(supplierSuggestions); - } - } - } else { - // Create individual orders for each suggestion - for (const suggestion of suggestions) { - if (!suggestion.supplierId) continue; - - const orderResult = await this.supplierOrderService.createOrder( - tenantId, - { - branchId: suggestion.branchId, - warehouseId: suggestion.warehouseId, - supplierId: suggestion.supplierId, - supplierName: suggestion.supplierName || '', - orderDate: new Date(), - expectedDate: data.expectedDate, - lines: [{ - productId: suggestion.productId, - productCode: suggestion.productCode, - productName: suggestion.productName, - quantityOrdered: suggestion.approvedQuantity || suggestion.suggestedQuantity, - unitPrice: suggestion.estimatedUnitCost || 0, - taxRate: 0.16, - }], - }, - userId - ); - - if (orderResult.success && orderResult.data) { - orders.push(orderResult.data); - - suggestion.status = SuggestionStatus.ORDERED; - suggestion.supplierOrderId = orderResult.data.id; - suggestion.supplierOrderNumber = orderResult.data.number; - suggestion.orderedQuantity = suggestion.approvedQuantity || suggestion.suggestedQuantity; - await queryRunner.manager.save(suggestion); - } - } - } - - await queryRunner.commitTransaction(); - - return { success: true, data: orders }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CONVERT_TO_ORDERS_FAILED', - message: error.message || 'Failed to convert suggestions to orders', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Generate purchase suggestions based on stock levels - */ - async generateSuggestions( - tenantId: string, - branchId: string, - warehouseId: string, - data: GenerateSuggestionsInput, - userId: string - ): Promise> { - // This is a simplified implementation - // In a real scenario, this would: - // 1. Query inventory levels from the inventory module - // 2. Compare against reorder points and min stock levels - // 3. Optionally use sales forecast data - // 4. Generate suggestions for items below threshold - - try { - // TODO: Integrate with inventory module to get actual stock data - // For now, return a placeholder response - - // Mark expired suggestions - await this.repository - .createQueryBuilder() - .update(PurchaseSuggestion) - .set({ status: SuggestionStatus.EXPIRED }) - .where('tenantId = :tenantId', { tenantId }) - .andWhere('branchId = :branchId', { branchId }) - .andWhere('status = :status', { status: SuggestionStatus.PENDING }) - .andWhere('expiresAt < :now', { now: new Date() }) - .execute(); - - return { - success: true, - data: { - generated: 0, - suggestions: [], - }, - }; - } catch (error: any) { - return { - success: false, - error: { - code: 'GENERATE_SUGGESTIONS_FAILED', - message: error.message || 'Failed to generate suggestions', - details: error, - }, - }; - } - } - - /** - * List suggestions with filters - */ - async listSuggestions( - tenantId: string, - branchId: string, - query: { - page?: number; - limit?: number; - status?: SuggestionStatus; - reason?: SuggestionReason; - supplierId?: string; - priority?: number; - startDate?: Date; - endDate?: Date; - search?: string; - } - ): Promise> { - const { - page = 1, - limit = 20, - status, - reason, - supplierId, - priority, - startDate, - endDate, - search, - } = query; - - const qb = this.repository.createQueryBuilder('suggestion') - .where('suggestion.tenantId = :tenantId', { tenantId }) - .andWhere('suggestion.branchId = :branchId', { branchId }); - - if (status) { - qb.andWhere('suggestion.status = :status', { status }); - } - - if (reason) { - qb.andWhere('suggestion.reason = :reason', { reason }); - } - - if (supplierId) { - qb.andWhere('suggestion.supplierId = :supplierId', { supplierId }); - } - - if (priority !== undefined) { - qb.andWhere('suggestion.priority = :priority', { priority }); - } - - if (startDate) { - qb.andWhere('suggestion.createdAt >= :startDate', { startDate }); - } - - if (endDate) { - qb.andWhere('suggestion.createdAt <= :endDate', { endDate }); - } - - if (search) { - qb.andWhere( - '(suggestion.productCode ILIKE :search OR suggestion.productName ILIKE :search)', - { search: `%${search}%` } - ); - } - - const total = await qb.getCount(); - - qb.orderBy('suggestion.priority', 'DESC') - .addOrderBy('suggestion.createdAt', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const data = await qb.getMany(); - const totalPages = Math.ceil(total / limit); - - return { - data, - pagination: { - page, - limit, - total, - totalPages, - hasNext: page < totalPages, - hasPrev: page > 1, - }, - }; - } - - /** - * Get suggestion statistics - */ - async getSuggestionStats( - tenantId: string, - branchId: string - ): Promise<{ - pending: number; - approved: number; - rejected: number; - ordered: number; - byPriority: Record; - byReason: Record; - totalEstimatedValue: number; - }> { - const suggestions = await this.repository.find({ - where: { tenantId, branchId }, - }); - - const pending = suggestions.filter(s => s.status === SuggestionStatus.PENDING).length; - const approved = suggestions.filter(s => s.status === SuggestionStatus.APPROVED).length; - const rejected = suggestions.filter(s => s.status === SuggestionStatus.REJECTED).length; - const ordered = suggestions.filter(s => s.status === SuggestionStatus.ORDERED).length; - - const byPriority: Record = { 0: 0, 1: 0, 2: 0, 3: 0 }; - const byReason: Record = {}; - let totalEstimatedValue = 0; - - for (const suggestion of suggestions) { - if (suggestion.status === SuggestionStatus.PENDING || suggestion.status === SuggestionStatus.APPROVED) { - byPriority[suggestion.priority] = (byPriority[suggestion.priority] || 0) + 1; - byReason[suggestion.reason] = (byReason[suggestion.reason] || 0) + 1; - if (suggestion.estimatedTotalCost) { - totalEstimatedValue += suggestion.estimatedTotalCost; - } - } - } - - return { - pending, - approved, - rejected, - ordered, - byPriority, - byReason, - totalEstimatedValue: Math.round(totalEstimatedValue * 100) / 100, - }; - } -} diff --git a/backend/src/modules/purchases/services/supplier-order.service.ts b/backend/src/modules/purchases/services/supplier-order.service.ts deleted file mode 100644 index f3727da..0000000 --- a/backend/src/modules/purchases/services/supplier-order.service.ts +++ /dev/null @@ -1,796 +0,0 @@ -import { Repository, DataSource, In } from 'typeorm'; -import { BaseService } from '../../../shared/services/base.service'; -import { ServiceResult, PaginatedResult } from '../../../shared/types'; -import { - SupplierOrder, - SupplierOrderStatus, - SupplierOrderLine, - OrderLineStatus, -} from '../entities'; -import { - CreateSupplierOrderInput, - UpdateSupplierOrderInput, -} from '../validation/purchases.schema'; - -export class SupplierOrderService extends BaseService { - private lineRepository: Repository; - - constructor( - private dataSource: DataSource - ) { - super(dataSource.getRepository(SupplierOrder)); - this.lineRepository = dataSource.getRepository(SupplierOrderLine); - } - - /** - * Generate order number - */ - private async generateOrderNumber(tenantId: string): Promise { - const date = new Date(); - const year = date.getFullYear().toString().slice(-2); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - - const count = await this.repository.count({ - where: { tenantId }, - }); - - const sequence = (count + 1).toString().padStart(5, '0'); - return `PO-${year}${month}-${sequence}`; - } - - /** - * Calculate order totals - */ - private calculateTotals(lines: SupplierOrderLine[], discountPercent = 0, shippingAmount = 0): { - subtotal: number; - discountAmount: number; - taxAmount: number; - total: number; - } { - let subtotal = 0; - let taxAmount = 0; - - for (const line of lines) { - const lineSubtotal = line.quantityOrdered * line.unitPrice; - const lineDiscount = line.discountAmount || (lineSubtotal * (line.discountPercent || 0) / 100); - const lineNet = lineSubtotal - lineDiscount; - const lineTax = lineNet * line.taxRate; - - subtotal += lineNet; - taxAmount += lineTax; - } - - const discountAmount = subtotal * (discountPercent / 100); - const total = subtotal - discountAmount + taxAmount + shippingAmount; - - return { - subtotal: Math.round(subtotal * 100) / 100, - discountAmount: Math.round(discountAmount * 100) / 100, - taxAmount: Math.round(taxAmount * 100) / 100, - total: Math.round(total * 100) / 100, - }; - } - - /** - * Create a new supplier order - */ - async createOrder( - tenantId: string, - data: CreateSupplierOrderInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const number = await this.generateOrderNumber(tenantId); - - // Create order lines - const lines: SupplierOrderLine[] = data.lines.map((lineData, index) => { - const lineSubtotal = lineData.quantityOrdered * lineData.unitPrice; - const lineDiscount = lineData.discountAmount || (lineSubtotal * (lineData.discountPercent || 0) / 100); - const lineNet = lineSubtotal - lineDiscount; - const lineTax = lineNet * (lineData.taxRate || 0.16); - - return queryRunner.manager.create(SupplierOrderLine, { - tenantId, - lineNumber: index + 1, - status: OrderLineStatus.PENDING, - productId: lineData.productId, - productCode: lineData.productCode, - productName: lineData.productName, - supplierProductCode: lineData.supplierProductCode, - variantId: lineData.variantId, - variantName: lineData.variantName, - uomId: lineData.uomId, - uomName: lineData.uomName || 'PZA', - quantityOrdered: lineData.quantityOrdered, - quantityPending: lineData.quantityOrdered, - quantityReceived: 0, - quantityCancelled: 0, - unitPrice: lineData.unitPrice, - discountPercent: lineData.discountPercent || 0, - discountAmount: lineDiscount, - taxId: lineData.taxId, - taxRate: lineData.taxRate || 0.16, - taxAmount: lineTax, - subtotal: lineNet, - total: lineNet + lineTax, - receivedValue: 0, - expectedDate: lineData.expectedDate, - notes: lineData.notes, - }); - }); - - const totals = this.calculateTotals( - lines, - data.discountPercent || 0, - data.shippingAmount || 0 - ); - - // Create order - const order = queryRunner.manager.create(SupplierOrder, { - tenantId, - branchId: data.branchId, - warehouseId: data.warehouseId, - number, - status: SupplierOrderStatus.DRAFT, - supplierId: data.supplierId, - supplierCode: data.supplierCode, - supplierName: data.supplierName, - supplierContact: data.supplierContact, - supplierEmail: data.supplierEmail, - supplierPhone: data.supplierPhone, - orderDate: data.orderDate, - expectedDate: data.expectedDate, - linesCount: lines.length, - ...totals, - discountPercent: data.discountPercent || 0, - shippingAmount: data.shippingAmount || 0, - currencyCode: data.currencyCode || 'MXN', - exchangeRate: data.exchangeRate || 1, - paymentTerms: data.paymentTerms, - shippingMethod: data.shippingMethod, - shippingAddress: data.shippingAddress, - quotationReference: data.quotationReference, - createdBy: userId, - notes: data.notes, - internalNotes: data.internalNotes, - }); - - const savedOrder = await queryRunner.manager.save(order); - - // Save lines with order reference - for (const line of lines) { - line.orderId = savedOrder.id; - } - await queryRunner.manager.save(lines); - - await queryRunner.commitTransaction(); - - // Return with lines - const result = await this.findById(tenantId, savedOrder.id, ['lines']); - return { success: true, data: result! }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'CREATE_ORDER_FAILED', - message: error.message || 'Failed to create supplier order', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Update supplier order - */ - async updateOrder( - tenantId: string, - orderId: string, - data: UpdateSupplierOrderInput, - userId: string - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const order = await this.findById(tenantId, orderId, ['lines']); - if (!order) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Order not found' }, - }; - } - - if (order.status !== SupplierOrderStatus.DRAFT) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Only draft orders can be updated' }, - }; - } - - // Update lines if provided - if (data.lines) { - // Remove existing lines - await queryRunner.manager.delete(SupplierOrderLine, { orderId }); - - // Create new lines - const lines: SupplierOrderLine[] = data.lines.map((lineData, index) => { - const lineSubtotal = lineData.quantityOrdered * lineData.unitPrice; - const lineDiscount = lineData.discountAmount || (lineSubtotal * (lineData.discountPercent || 0) / 100); - const lineNet = lineSubtotal - lineDiscount; - const lineTax = lineNet * (lineData.taxRate || 0.16); - - return queryRunner.manager.create(SupplierOrderLine, { - tenantId, - orderId, - lineNumber: index + 1, - status: OrderLineStatus.PENDING, - productId: lineData.productId, - productCode: lineData.productCode, - productName: lineData.productName, - supplierProductCode: lineData.supplierProductCode, - variantId: lineData.variantId, - variantName: lineData.variantName, - uomId: lineData.uomId, - uomName: lineData.uomName || 'PZA', - quantityOrdered: lineData.quantityOrdered, - quantityPending: lineData.quantityOrdered, - quantityReceived: 0, - quantityCancelled: 0, - unitPrice: lineData.unitPrice, - discountPercent: lineData.discountPercent || 0, - discountAmount: lineDiscount, - taxId: lineData.taxId, - taxRate: lineData.taxRate || 0.16, - taxAmount: lineTax, - subtotal: lineNet, - total: lineNet + lineTax, - receivedValue: 0, - expectedDate: lineData.expectedDate, - notes: lineData.notes, - }); - }); - - await queryRunner.manager.save(lines); - - const totals = this.calculateTotals( - lines, - data.discountPercent ?? order.discountPercent, - data.shippingAmount ?? order.shippingAmount - ); - - Object.assign(order, { - linesCount: lines.length, - ...totals, - }); - } - - // Update order fields - const { lines: _, ...updateData } = data; - Object.assign(order, updateData); - - const savedOrder = await queryRunner.manager.save(order); - await queryRunner.commitTransaction(); - - const result = await this.findById(tenantId, savedOrder.id, ['lines']); - return { success: true, data: result! }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'UPDATE_ORDER_FAILED', - message: error.message || 'Failed to update order', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * Submit order for approval - */ - async submitForApproval( - tenantId: string, - orderId: string, - userId: string, - notes?: string - ): Promise> { - const order = await this.findById(tenantId, orderId); - if (!order) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Order not found' }, - }; - } - - if (order.status !== SupplierOrderStatus.DRAFT) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Order must be in draft status' }, - }; - } - - order.status = SupplierOrderStatus.PENDING_APPROVAL; - if (notes) { - order.internalNotes = order.internalNotes - ? `${order.internalNotes}\n[Submitted] ${notes}` - : `[Submitted] ${notes}`; - } - - const saved = await this.repository.save(order); - return { success: true, data: saved }; - } - - /** - * Approve order - */ - async approveOrder( - tenantId: string, - orderId: string, - userId: string, - notes?: string - ): Promise> { - const order = await this.findById(tenantId, orderId); - if (!order) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Order not found' }, - }; - } - - if (order.status !== SupplierOrderStatus.PENDING_APPROVAL) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Order must be pending approval' }, - }; - } - - order.status = SupplierOrderStatus.APPROVED; - order.approvedBy = userId; - order.approvedAt = new Date(); - if (notes) { - order.internalNotes = order.internalNotes - ? `${order.internalNotes}\n[Approved] ${notes}` - : `[Approved] ${notes}`; - } - - const saved = await this.repository.save(order); - return { success: true, data: saved }; - } - - /** - * Reject order - */ - async rejectOrder( - tenantId: string, - orderId: string, - userId: string, - reason: string - ): Promise> { - const order = await this.findById(tenantId, orderId); - if (!order) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Order not found' }, - }; - } - - if (order.status !== SupplierOrderStatus.PENDING_APPROVAL) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Order must be pending approval' }, - }; - } - - order.status = SupplierOrderStatus.DRAFT; - order.internalNotes = order.internalNotes - ? `${order.internalNotes}\n[Rejected] ${reason}` - : `[Rejected] ${reason}`; - - const saved = await this.repository.save(order); - return { success: true, data: saved }; - } - - /** - * Send order to supplier - */ - async sendToSupplier( - tenantId: string, - orderId: string, - userId: string, - method: 'email' | 'manual', - emailTo?: string, - notes?: string - ): Promise> { - const order = await this.findById(tenantId, orderId); - if (!order) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Order not found' }, - }; - } - - if (order.status !== SupplierOrderStatus.APPROVED) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Order must be approved before sending' }, - }; - } - - order.status = SupplierOrderStatus.SENT; - order.sentAt = new Date(); - order.sentBy = userId; - - if (method === 'email' && emailTo) { - // TODO: Implement actual email sending - order.emailSent = true; - order.emailSentAt = new Date(); - order.emailSentTo = emailTo; - } - - if (notes) { - order.internalNotes = order.internalNotes - ? `${order.internalNotes}\n[Sent] ${notes}` - : `[Sent] ${notes}`; - } - - const saved = await this.repository.save(order); - return { success: true, data: saved }; - } - - /** - * Confirm order by supplier - */ - async supplierConfirmation( - tenantId: string, - orderId: string, - supplierReference?: string, - expectedDate?: Date, - notes?: string - ): Promise> { - const order = await this.findById(tenantId, orderId); - if (!order) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Order not found' }, - }; - } - - if (order.status !== SupplierOrderStatus.SENT) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Order must be sent to supplier' }, - }; - } - - order.status = SupplierOrderStatus.CONFIRMED; - order.confirmedAt = new Date(); - if (supplierReference) { - order.supplierReference = supplierReference; - } - if (expectedDate) { - order.expectedDate = expectedDate; - } - if (notes) { - order.notes = order.notes - ? `${order.notes}\n[Confirmed] ${notes}` - : `[Confirmed] ${notes}`; - } - - const saved = await this.repository.save(order); - return { success: true, data: saved }; - } - - /** - * Cancel order - */ - async cancelOrder( - tenantId: string, - orderId: string, - userId: string, - reason: string - ): Promise> { - const order = await this.findById(tenantId, orderId, ['lines']); - if (!order) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Order not found' }, - }; - } - - const cancellableStatuses = [ - SupplierOrderStatus.DRAFT, - SupplierOrderStatus.PENDING_APPROVAL, - SupplierOrderStatus.APPROVED, - SupplierOrderStatus.SENT, - SupplierOrderStatus.CONFIRMED, - ]; - - if (!cancellableStatuses.includes(order.status)) { - return { - success: false, - error: { code: 'INVALID_STATUS', message: 'Order cannot be cancelled in current status' }, - }; - } - - order.status = SupplierOrderStatus.CANCELLED; - order.cancelledAt = new Date(); - order.cancelledBy = userId; - order.cancellationReason = reason; - - // Cancel all pending lines - for (const line of order.lines) { - if (line.status === OrderLineStatus.PENDING) { - line.status = OrderLineStatus.CANCELLED; - line.quantityCancelled = line.quantityPending; - line.quantityPending = 0; - } - } - - await this.lineRepository.save(order.lines); - const saved = await this.repository.save(order); - - return { success: true, data: saved }; - } - - /** - * Update order status based on receipts - */ - async updateOrderFromReceipt( - tenantId: string, - orderId: string, - receivedLines: { lineId: string; quantityReceived: number; value: number }[] - ): Promise> { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const order = await this.findById(tenantId, orderId, ['lines']); - if (!order) { - return { - success: false, - error: { code: 'NOT_FOUND', message: 'Order not found' }, - }; - } - - let allReceived = true; - let anyReceived = false; - - for (const receivedLine of receivedLines) { - const line = order.lines.find(l => l.id === receivedLine.lineId); - if (line) { - line.quantityReceived += receivedLine.quantityReceived; - line.quantityPending = Math.max(0, line.quantityOrdered - line.quantityReceived - line.quantityCancelled); - line.receivedValue += receivedLine.value; - line.lastReceiptDate = new Date() as any; - line.lastReceiptQuantity = receivedLine.quantityReceived; - - if (line.quantityPending === 0) { - line.status = OrderLineStatus.RECEIVED; - } else if (line.quantityReceived > 0) { - line.status = OrderLineStatus.PARTIALLY_RECEIVED; - } - - if (line.quantityPending > 0) { - allReceived = false; - } - if (line.quantityReceived > 0) { - anyReceived = true; - } - } - } - - await queryRunner.manager.save(order.lines); - - // Update order totals and status - order.totalReceivedValue = order.lines.reduce((sum, l) => sum + l.receivedValue, 0); - order.lastReceiptDate = new Date(); - - if (allReceived) { - order.status = SupplierOrderStatus.RECEIVED; - } else if (anyReceived) { - order.status = SupplierOrderStatus.PARTIALLY_RECEIVED; - } - - const saved = await queryRunner.manager.save(order); - await queryRunner.commitTransaction(); - - return { success: true, data: saved }; - } catch (error: any) { - await queryRunner.rollbackTransaction(); - return { - success: false, - error: { - code: 'UPDATE_FROM_RECEIPT_FAILED', - message: error.message || 'Failed to update order from receipt', - details: error, - }, - }; - } finally { - await queryRunner.release(); - } - } - - /** - * List supplier orders with filters - */ - async listOrders( - tenantId: string, - branchId: string, - query: { - page?: number; - limit?: number; - status?: SupplierOrderStatus; - supplierId?: string; - startDate?: Date; - endDate?: Date; - search?: string; - } - ): Promise> { - const { - page = 1, - limit = 20, - status, - supplierId, - startDate, - endDate, - search, - } = query; - - const qb = this.repository.createQueryBuilder('order') - .where('order.tenantId = :tenantId', { tenantId }) - .andWhere('order.branchId = :branchId', { branchId }); - - if (status) { - qb.andWhere('order.status = :status', { status }); - } - - if (supplierId) { - qb.andWhere('order.supplierId = :supplierId', { supplierId }); - } - - if (startDate) { - qb.andWhere('order.orderDate >= :startDate', { startDate }); - } - - if (endDate) { - qb.andWhere('order.orderDate <= :endDate', { endDate }); - } - - if (search) { - qb.andWhere( - '(order.number ILIKE :search OR order.supplierName ILIKE :search OR order.supplierReference ILIKE :search)', - { search: `%${search}%` } - ); - } - - const total = await qb.getCount(); - - qb.orderBy('order.createdAt', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const data = await qb.getMany(); - const totalPages = Math.ceil(total / limit); - - return { - data, - pagination: { - page, - limit, - total, - totalPages, - hasNext: page < totalPages, - hasPrev: page > 1, - }, - }; - } - - /** - * Get pending orders by supplier - */ - async getPendingOrdersBySupplier( - tenantId: string, - supplierId: string - ): Promise { - return this.repository.find({ - where: { - tenantId, - supplierId, - status: In([ - SupplierOrderStatus.APPROVED, - SupplierOrderStatus.SENT, - SupplierOrderStatus.CONFIRMED, - SupplierOrderStatus.PARTIALLY_RECEIVED, - ]), - }, - relations: ['lines'], - order: { orderDate: 'ASC' }, - }); - } - - /** - * Get order statistics - */ - async getOrderStats( - tenantId: string, - branchId: string, - startDate: Date, - endDate: Date - ): Promise<{ - totalOrders: number; - totalValue: number; - avgOrderValue: number; - byStatus: Record; - topSuppliers: { supplierId: string; supplierName: string; orders: number; value: number }[]; - }> { - const orders = await this.repository.find({ - where: { - tenantId, - branchId, - }, - }); - - // Filter by date range - const filteredOrders = orders.filter(o => { - const orderDate = new Date(o.orderDate); - return orderDate >= startDate && orderDate <= endDate; - }); - - const totalValue = filteredOrders.reduce((sum, o) => sum + Number(o.total), 0); - const avgOrderValue = filteredOrders.length > 0 ? totalValue / filteredOrders.length : 0; - - // Group by status - const byStatus: Record = {}; - for (const order of filteredOrders) { - if (!byStatus[order.status]) { - byStatus[order.status] = { count: 0, value: 0 }; - } - byStatus[order.status].count++; - byStatus[order.status].value += Number(order.total); - } - - // Group by supplier - const supplierMap = new Map(); - for (const order of filteredOrders) { - const existing = supplierMap.get(order.supplierId) || { - name: order.supplierName, - orders: 0, - value: 0, - }; - existing.orders++; - existing.value += Number(order.total); - supplierMap.set(order.supplierId, existing); - } - - const topSuppliers = Array.from(supplierMap.entries()) - .map(([supplierId, data]) => ({ - supplierId, - supplierName: data.name, - orders: data.orders, - value: data.value, - })) - .sort((a, b) => b.value - a.value) - .slice(0, 10); - - return { - totalOrders: filteredOrders.length, - totalValue: Math.round(totalValue * 100) / 100, - avgOrderValue: Math.round(avgOrderValue * 100) / 100, - byStatus, - topSuppliers, - }; - } -} diff --git a/backend/src/modules/purchases/validation/index.ts b/backend/src/modules/purchases/validation/index.ts deleted file mode 100644 index 00eee6e..0000000 --- a/backend/src/modules/purchases/validation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './purchases.schema'; diff --git a/backend/src/modules/purchases/validation/purchases.schema.ts b/backend/src/modules/purchases/validation/purchases.schema.ts deleted file mode 100644 index a358bd5..0000000 --- a/backend/src/modules/purchases/validation/purchases.schema.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { z } from 'zod'; -import { - uuidSchema, - moneySchema, - quantitySchema, - percentSchema, - paginationSchema, - addressSchema, - emailSchema, - phoneSchema, -} from '../../../shared/validation/common.schema'; - -// ==================== ENUMS ==================== - -export const supplierOrderStatusEnum = z.enum([ - 'draft', - 'pending_approval', - 'approved', - 'sent', - 'confirmed', - 'partially_received', - 'received', - 'cancelled', -]); - -export const orderLineStatusEnum = z.enum([ - 'pending', - 'partially_received', - 'received', - 'cancelled', -]); - -export const receiptStatusEnum = z.enum([ - 'draft', - 'in_progress', - 'completed', - 'posted', - 'cancelled', -]); - -export const suggestionStatusEnum = z.enum([ - 'pending', - 'reviewed', - 'approved', - 'ordered', - 'rejected', - 'expired', -]); - -export const suggestionReasonEnum = z.enum([ - 'low_stock', - 'out_of_stock', - 'reorder_point', - 'sales_forecast', - 'seasonal', - 'manual', -]); - -export const qualityStatusEnum = z.enum([ - 'not_checked', - 'passed', - 'failed', - 'partial', -]); - -// ==================== SUPPLIER ORDER SCHEMAS ==================== - -// Order line schema -export const supplierOrderLineSchema = z.object({ - productId: uuidSchema, - productCode: z.string().min(1).max(50), - productName: z.string().min(1).max(200), - supplierProductCode: z.string().max(50).optional(), - variantId: uuidSchema.optional(), - variantName: z.string().max(200).optional(), - uomId: uuidSchema.optional(), - uomName: z.string().max(20).default('PZA'), - quantityOrdered: quantitySchema.positive('Quantity must be positive'), - unitPrice: moneySchema, - discountPercent: percentSchema.optional(), - discountAmount: moneySchema.optional(), - taxId: uuidSchema.optional(), - taxRate: z.coerce.number().min(0).max(1).default(0.16), - expectedDate: z.coerce.date().optional(), - notes: z.string().max(500).optional(), -}); - -// Create supplier order schema -export const createSupplierOrderSchema = z.object({ - branchId: uuidSchema, - warehouseId: uuidSchema, - supplierId: uuidSchema, - supplierCode: z.string().max(50).optional(), - supplierName: z.string().min(1).max(200), - supplierContact: z.string().max(100).optional(), - supplierEmail: emailSchema.optional(), - supplierPhone: phoneSchema.optional(), - orderDate: z.coerce.date(), - expectedDate: z.coerce.date().optional(), - currencyCode: z.string().length(3).default('MXN'), - exchangeRate: z.coerce.number().positive().default(1), - paymentTerms: z.string().max(50).optional(), - shippingMethod: z.string().max(100).optional(), - shippingAddress: addressSchema.optional(), - discountPercent: percentSchema.optional(), - discountAmount: moneySchema.optional(), - shippingAmount: moneySchema.optional(), - quotationReference: z.string().max(50).optional(), - lines: z.array(supplierOrderLineSchema).min(1, 'At least one line is required'), - notes: z.string().max(1000).optional(), - internalNotes: z.string().max(1000).optional(), -}); - -// Update supplier order schema -export const updateSupplierOrderSchema = createSupplierOrderSchema.partial().omit({ - branchId: true, - warehouseId: true, -}).extend({ - lines: z.array(supplierOrderLineSchema.extend({ - id: uuidSchema.optional(), // For updates - })).optional(), -}); - -// Submit for approval schema -export const submitForApprovalSchema = z.object({ - notes: z.string().max(500).optional(), -}); - -// Approve order schema -export const approveOrderSchema = z.object({ - notes: z.string().max(500).optional(), -}); - -// Reject order schema -export const rejectOrderSchema = z.object({ - reason: z.string().min(1, 'Rejection reason is required').max(500), -}); - -// Send order to supplier schema -export const sendToSupplierSchema = z.object({ - method: z.enum(['email', 'manual']), - emailTo: emailSchema.optional(), - notes: z.string().max(500).optional(), -}); - -// Supplier confirmation schema -export const supplierConfirmationSchema = z.object({ - supplierReference: z.string().max(50).optional(), - expectedDate: z.coerce.date().optional(), - notes: z.string().max(500).optional(), -}); - -// Cancel order schema -export const cancelOrderSchema = z.object({ - reason: z.string().min(1, 'Cancellation reason is required').max(255), -}); - -// List supplier orders query schema -export const listSupplierOrdersQuerySchema = paginationSchema.extend({ - status: supplierOrderStatusEnum.optional(), - supplierId: uuidSchema.optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - search: z.string().optional(), -}); - -// ==================== GOODS RECEIPT SCHEMAS ==================== - -// Receipt line schema -export const receiptLineSchema = z.object({ - orderLineId: uuidSchema.optional(), - productId: uuidSchema, - productCode: z.string().min(1).max(50), - productName: z.string().min(1).max(200), - variantId: uuidSchema.optional(), - variantName: z.string().max(200).optional(), - uomId: uuidSchema.optional(), - uomName: z.string().max(20).default('PZA'), - quantityExpected: quantitySchema.optional(), - quantityReceived: quantitySchema.min(0), - quantityRejected: quantitySchema.optional(), - unitCost: moneySchema, - taxRate: z.coerce.number().min(0).max(1).default(0.16), - locationId: uuidSchema.optional(), - locationCode: z.string().max(50).optional(), - lotNumber: z.string().max(50).optional(), - serialNumbers: z.array(z.string().max(100)).optional(), - expiryDate: z.coerce.date().optional(), - manufactureDate: z.coerce.date().optional(), - qualityStatus: qualityStatusEnum.optional(), - qualityNotes: z.string().max(500).optional(), - rejectionReason: z.string().max(255).optional(), - notes: z.string().max(500).optional(), -}); - -// Create goods receipt schema -export const createGoodsReceiptSchema = z.object({ - branchId: uuidSchema, - warehouseId: uuidSchema, - receiptDate: z.coerce.date(), - supplierOrderId: uuidSchema.optional(), - supplierId: uuidSchema, - supplierName: z.string().min(1).max(200), - supplierInvoiceNumber: z.string().max(50).optional(), - supplierDeliveryNote: z.string().max(50).optional(), - currencyCode: z.string().length(3).default('MXN'), - exchangeRate: z.coerce.number().positive().default(1), - deliveryPerson: z.string().max(100).optional(), - vehiclePlate: z.string().max(20).optional(), - lines: z.array(receiptLineSchema).min(1, 'At least one line is required'), - notes: z.string().max(1000).optional(), -}); - -// Update goods receipt schema -export const updateGoodsReceiptSchema = createGoodsReceiptSchema.partial().omit({ - branchId: true, - warehouseId: true, - supplierOrderId: true, -}).extend({ - lines: z.array(receiptLineSchema.extend({ - id: uuidSchema.optional(), - })).optional(), -}); - -// Create receipt from order schema -export const createReceiptFromOrderSchema = z.object({ - supplierOrderId: uuidSchema, - receiptDate: z.coerce.date(), - supplierInvoiceNumber: z.string().max(50).optional(), - supplierDeliveryNote: z.string().max(50).optional(), - deliveryPerson: z.string().max(100).optional(), - vehiclePlate: z.string().max(20).optional(), - lines: z.array(z.object({ - orderLineId: uuidSchema, - quantityReceived: quantitySchema.min(0), - quantityRejected: quantitySchema.optional(), - locationId: uuidSchema.optional(), - lotNumber: z.string().max(50).optional(), - serialNumbers: z.array(z.string().max(100)).optional(), - expiryDate: z.coerce.date().optional(), - qualityStatus: qualityStatusEnum.optional(), - qualityNotes: z.string().max(500).optional(), - rejectionReason: z.string().max(255).optional(), - notes: z.string().max(500).optional(), - })).min(1), - notes: z.string().max(1000).optional(), -}); - -// Complete quality check schema -export const completeQualityCheckSchema = z.object({ - passed: z.boolean(), - notes: z.string().max(1000).optional(), - lineChecks: z.array(z.object({ - lineId: uuidSchema, - qualityStatus: qualityStatusEnum, - notes: z.string().max(500).optional(), - rejectionReason: z.string().max(255).optional(), - })).optional(), -}); - -// Post receipt schema -export const postReceiptSchema = z.object({ - notes: z.string().max(500).optional(), -}); - -// Cancel receipt schema -export const cancelReceiptSchema = z.object({ - reason: z.string().min(1, 'Cancellation reason is required').max(255), -}); - -// List receipts query schema -export const listReceiptsQuerySchema = paginationSchema.extend({ - status: receiptStatusEnum.optional(), - supplierId: uuidSchema.optional(), - supplierOrderId: uuidSchema.optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - search: z.string().optional(), - hasDiscrepancies: z.coerce.boolean().optional(), -}); - -// ==================== PURCHASE SUGGESTION SCHEMAS ==================== - -// Create manual suggestion schema -export const createSuggestionSchema = z.object({ - branchId: uuidSchema, - warehouseId: uuidSchema, - productId: uuidSchema, - productCode: z.string().min(1).max(50), - productName: z.string().min(1).max(200), - supplierId: uuidSchema.optional(), - supplierName: z.string().max(200).optional(), - reason: suggestionReasonEnum.default('manual'), - suggestedQuantity: quantitySchema.positive('Quantity must be positive'), - estimatedUnitCost: moneySchema.optional(), - priority: z.coerce.number().int().min(0).max(3).default(0), - dueDate: z.coerce.date().optional(), - notes: z.string().max(1000).optional(), -}); - -// Update suggestion schema -export const updateSuggestionSchema = z.object({ - supplierId: uuidSchema.optional(), - supplierName: z.string().max(200).optional(), - suggestedQuantity: quantitySchema.positive().optional(), - estimatedUnitCost: moneySchema.optional(), - priority: z.coerce.number().int().min(0).max(3).optional(), - dueDate: z.coerce.date().optional().nullable(), - notes: z.string().max(1000).optional(), -}); - -// Review suggestion schema -export const reviewSuggestionSchema = z.object({ - action: z.enum(['approve', 'reject', 'modify']), - approvedQuantity: quantitySchema.optional(), - supplierId: uuidSchema.optional(), - notes: z.string().max(500).optional(), -}); - -// Bulk review suggestions schema -export const bulkReviewSuggestionsSchema = z.object({ - suggestionIds: z.array(uuidSchema).min(1), - action: z.enum(['approve', 'reject']), - notes: z.string().max(500).optional(), -}); - -// Convert suggestions to orders schema -export const convertSuggestionsToOrdersSchema = z.object({ - suggestionIds: z.array(uuidSchema).min(1), - groupBySupplier: z.boolean().default(true), - expectedDate: z.coerce.date().optional(), -}); - -// Generate suggestions schema -export const generateSuggestionsSchema = z.object({ - warehouseId: uuidSchema.optional(), - categoryIds: z.array(uuidSchema).optional(), - includeOutOfStock: z.boolean().default(true), - includeBelowReorderPoint: z.boolean().default(true), - useSalesForecast: z.boolean().default(false), - forecastDays: z.coerce.number().int().min(7).max(90).default(30), -}); - -// List suggestions query schema -export const listSuggestionsQuerySchema = paginationSchema.extend({ - status: suggestionStatusEnum.optional(), - reason: suggestionReasonEnum.optional(), - supplierId: uuidSchema.optional(), - priority: z.coerce.number().int().min(0).max(3).optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - search: z.string().optional(), -}); - -// ==================== REPORT SCHEMAS ==================== - -// Purchases summary report schema -export const purchasesSummaryReportSchema = z.object({ - startDate: z.coerce.date(), - endDate: z.coerce.date(), - groupBy: z.enum(['day', 'week', 'month', 'supplier', 'category']).default('day'), - supplierId: uuidSchema.optional(), - warehouseId: uuidSchema.optional(), -}); - -// Supplier performance report schema -export const supplierPerformanceReportSchema = z.object({ - startDate: z.coerce.date(), - endDate: z.coerce.date(), - supplierIds: z.array(uuidSchema).optional(), -}); - -// Type exports -export type CreateSupplierOrderInput = z.infer; -export type UpdateSupplierOrderInput = z.infer; -export type SupplierOrderLineInput = z.infer; -export type CreateGoodsReceiptInput = z.infer; -export type UpdateGoodsReceiptInput = z.infer; -export type CreateReceiptFromOrderInput = z.infer; -export type ReceiptLineInput = z.infer; -export type CreateSuggestionInput = z.infer; -export type UpdateSuggestionInput = z.infer; -export type ReviewSuggestionInput = z.infer; -export type ConvertSuggestionsInput = z.infer; -export type GenerateSuggestionsInput = z.infer; diff --git a/backend/src/shared/controllers/base.controller.ts b/backend/src/shared/controllers/base.controller.ts deleted file mode 100644 index b49e4fa..0000000 --- a/backend/src/shared/controllers/base.controller.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Response } from 'express'; -import { AuthenticatedRequest, ApiResponse, PaginatedResult } from '../types'; - -/** - * Base controller with common response methods - */ -export abstract class BaseController { - /** - * Send success response - */ - protected success(res: Response, data: T, statusCode: number = 200): Response { - const response: ApiResponse = { - success: true, - data, - meta: { - timestamp: new Date().toISOString(), - }, - }; - return res.status(statusCode).json(response); - } - - /** - * Send paginated response - */ - protected paginated(res: Response, result: PaginatedResult): Response { - const response: ApiResponse & { pagination: PaginatedResult['pagination'] } = { - success: true, - data: result.data, - pagination: result.pagination, - meta: { - timestamp: new Date().toISOString(), - }, - }; - return res.status(200).json(response); - } - - /** - * Send error response - */ - protected error( - res: Response, - code: string, - message: string, - statusCode: number = 400, - details?: any - ): Response { - const response: ApiResponse = { - success: false, - error: { - code, - message, - ...(details && { details }), - }, - meta: { - timestamp: new Date().toISOString(), - }, - }; - return res.status(statusCode).json(response); - } - - /** - * Send not found error - */ - protected notFound(res: Response, resource: string = 'Resource'): Response { - return this.error(res, 'NOT_FOUND', `${resource} not found`, 404); - } - - /** - * Send validation error - */ - protected validationError(res: Response, details: any): Response { - return this.error(res, 'VALIDATION_ERROR', 'Validation failed', 400, details); - } - - /** - * Send unauthorized error - */ - protected unauthorized(res: Response, message: string = 'Unauthorized'): Response { - return this.error(res, 'UNAUTHORIZED', message, 401); - } - - /** - * Send forbidden error - */ - protected forbidden(res: Response, message: string = 'Forbidden'): Response { - return this.error(res, 'FORBIDDEN', message, 403); - } - - /** - * Send conflict error - */ - protected conflict(res: Response, message: string): Response { - return this.error(res, 'CONFLICT', message, 409); - } - - /** - * Send internal error - */ - protected internalError(res: Response, error?: Error): Response { - console.error('Internal error:', error); - return this.error( - res, - 'INTERNAL_ERROR', - 'An unexpected error occurred', - 500, - process.env.NODE_ENV === 'development' ? error?.message : undefined - ); - } - - /** - * Get tenant ID from request - */ - protected getTenantId(req: AuthenticatedRequest): string { - return req.tenant.tenantId; - } - - /** - * Get user ID from request - */ - protected getUserId(req: AuthenticatedRequest): string { - return req.user.userId; - } - - /** - * Get branch ID from request - */ - protected getBranchId(req: AuthenticatedRequest): string | undefined { - return req.branch?.branchId; - } - - /** - * Parse pagination from query - */ - protected parsePagination(query: any): { - page: number; - limit: number; - sortBy?: string; - sortOrder?: 'ASC' | 'DESC'; - } { - return { - page: Math.max(1, parseInt(query.page) || 1), - limit: Math.min(100, Math.max(1, parseInt(query.limit) || 20)), - sortBy: query.sortBy, - sortOrder: query.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC', - }; - } -} diff --git a/backend/src/shared/index.ts b/backend/src/shared/index.ts deleted file mode 100644 index 68d10ce..0000000 --- a/backend/src/shared/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './types'; -export * from './services/base.service'; -export * from './middleware'; diff --git a/backend/src/shared/middleware/auth.middleware.ts b/backend/src/shared/middleware/auth.middleware.ts deleted file mode 100644 index c292e09..0000000 --- a/backend/src/shared/middleware/auth.middleware.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import jwt from 'jsonwebtoken'; -import { AuthenticatedRequest, UserContext } from '../types'; - -const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; - -interface JWTPayload { - sub: string; - email: string; - name: string; - roles: string[]; - permissions?: string[]; - tenantId: string; - iat: number; - exp: number; -} - -/** - * Authentication middleware - validates JWT token - */ -export function authMiddleware( - req: Request, - res: Response, - next: NextFunction -): void { - const authHeader = req.headers.authorization; - - if (!authHeader) { - res.status(401).json({ - success: false, - error: { - code: 'MISSING_AUTH', - message: 'Authorization header is required', - }, - }); - return; - } - - const parts = authHeader.split(' '); - if (parts.length !== 2 || parts[0] !== 'Bearer') { - res.status(401).json({ - success: false, - error: { - code: 'INVALID_AUTH_FORMAT', - message: 'Authorization header must be: Bearer ', - }, - }); - return; - } - - const token = parts[1]; - - try { - const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; - - // Attach user context to request - const user: UserContext = { - userId: decoded.sub, - email: decoded.email, - name: decoded.name, - roles: decoded.roles || [], - permissions: decoded.permissions, - }; - - (req as AuthenticatedRequest).user = user; - - // Also set tenant from token if not already set - if (!(req as AuthenticatedRequest).tenant && decoded.tenantId) { - (req as AuthenticatedRequest).tenant = { tenantId: decoded.tenantId }; - } - - next(); - } catch (error: any) { - if (error.name === 'TokenExpiredError') { - res.status(401).json({ - success: false, - error: { - code: 'TOKEN_EXPIRED', - message: 'Authentication token has expired', - }, - }); - return; - } - - res.status(401).json({ - success: false, - error: { - code: 'INVALID_TOKEN', - message: 'Invalid authentication token', - }, - }); - } -} - -/** - * Optional auth middleware - sets user if token provided but doesn't require it - */ -export function optionalAuthMiddleware( - req: Request, - res: Response, - next: NextFunction -): void { - const authHeader = req.headers.authorization; - - if (!authHeader) { - next(); - return; - } - - const parts = authHeader.split(' '); - if (parts.length !== 2 || parts[0] !== 'Bearer') { - next(); - return; - } - - const token = parts[1]; - - try { - const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; - (req as AuthenticatedRequest).user = { - userId: decoded.sub, - email: decoded.email, - name: decoded.name, - roles: decoded.roles || [], - permissions: decoded.permissions, - }; - - if (decoded.tenantId) { - (req as AuthenticatedRequest).tenant = { tenantId: decoded.tenantId }; - } - } catch { - // Ignore errors for optional auth - } - - next(); -} - -/** - * Role-based authorization middleware - */ -export function requireRoles(...allowedRoles: string[]) { - return (req: Request, res: Response, next: NextFunction): void => { - const user = (req as AuthenticatedRequest).user; - - if (!user) { - res.status(401).json({ - success: false, - error: { - code: 'NOT_AUTHENTICATED', - message: 'Authentication required', - }, - }); - return; - } - - const hasRole = user.roles.some((role) => allowedRoles.includes(role)); - - if (!hasRole) { - res.status(403).json({ - success: false, - error: { - code: 'FORBIDDEN', - message: 'Insufficient permissions', - }, - }); - return; - } - - next(); - }; -} - -/** - * Permission-based authorization middleware - */ -export function requirePermissions(...requiredPermissions: string[]) { - return (req: Request, res: Response, next: NextFunction): void => { - const user = (req as AuthenticatedRequest).user; - - if (!user) { - res.status(401).json({ - success: false, - error: { - code: 'NOT_AUTHENTICATED', - message: 'Authentication required', - }, - }); - return; - } - - const userPermissions = user.permissions || []; - const hasAllPermissions = requiredPermissions.every((perm) => - userPermissions.includes(perm) - ); - - if (!hasAllPermissions) { - res.status(403).json({ - success: false, - error: { - code: 'FORBIDDEN', - message: 'Missing required permissions', - }, - }); - return; - } - - next(); - }; -} diff --git a/backend/src/shared/middleware/branch.middleware.ts b/backend/src/shared/middleware/branch.middleware.ts deleted file mode 100644 index 3e2bfbb..0000000 --- a/backend/src/shared/middleware/branch.middleware.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { AuthenticatedRequest, BranchContext } from '../types'; -import { AppDataSource } from '../../config/typeorm'; -import { Branch } from '../../modules/branches/entities/branch.entity'; - -/** - * Middleware to extract and validate branch context from request headers - * - * Expected header: X-Branch-ID - */ -export function branchMiddleware( - req: Request, - res: Response, - next: NextFunction -): void { - const branchId = req.headers['x-branch-id'] as string; - - if (!branchId) { - res.status(400).json({ - success: false, - error: { - code: 'MISSING_BRANCH', - message: 'X-Branch-ID header is required', - }, - }); - return; - } - - // Validate UUID format - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - if (!uuidRegex.test(branchId)) { - res.status(400).json({ - success: false, - error: { - code: 'INVALID_BRANCH', - message: 'Invalid branch ID format', - }, - }); - return; - } - - // Attach branch context to request - const branch: BranchContext = { - branchId, - }; - - (req as AuthenticatedRequest).branch = branch; - next(); -} - -/** - * Optional branch middleware - sets branch if provided but doesn't require it - */ -export function optionalBranchMiddleware( - req: Request, - res: Response, - next: NextFunction -): void { - const branchId = req.headers['x-branch-id'] as string; - - if (branchId) { - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - if (uuidRegex.test(branchId)) { - (req as AuthenticatedRequest).branch = { branchId }; - } - } - - next(); -} - -/** - * Middleware that validates branch exists and belongs to tenant - * Must be used after tenantMiddleware - */ -export async function validateBranchMiddleware( - req: Request, - res: Response, - next: NextFunction -): Promise { - const authReq = req as AuthenticatedRequest; - const branchId = authReq.branch?.branchId; - const tenantId = authReq.tenant?.tenantId; - - if (!branchId || !tenantId) { - next(); - return; - } - - try { - const branchRepository = AppDataSource.getRepository(Branch); - const branch = await branchRepository.findOne({ - where: { id: branchId, tenantId }, - select: ['id', 'code', 'name', 'warehouseId', 'status'], - }); - - if (!branch) { - res.status(404).json({ - success: false, - error: { - code: 'BRANCH_NOT_FOUND', - message: 'Branch not found or does not belong to this tenant', - }, - }); - return; - } - - if (branch.status !== 'active') { - res.status(400).json({ - success: false, - error: { - code: 'BRANCH_INACTIVE', - message: 'Branch is not active', - }, - }); - return; - } - - // Enrich branch context - authReq.branch = { - branchId: branch.id, - branchCode: branch.code, - branchName: branch.name, - warehouseId: branch.warehouseId, - }; - - next(); - } catch (error) { - console.error('Error validating branch:', error); - res.status(500).json({ - success: false, - error: { - code: 'BRANCH_VALIDATION_ERROR', - message: 'Error validating branch', - }, - }); - } -} - -/** - * Middleware to require specific branch capabilities - */ -export function requireBranchCapability(capability: 'pos' | 'inventory' | 'ecommerce') { - return async (req: Request, res: Response, next: NextFunction): Promise => { - const authReq = req as AuthenticatedRequest; - const branchId = authReq.branch?.branchId; - const tenantId = authReq.tenant?.tenantId; - - if (!branchId || !tenantId) { - res.status(400).json({ - success: false, - error: { - code: 'MISSING_CONTEXT', - message: 'Branch and tenant context required', - }, - }); - return; - } - - try { - const branchRepository = AppDataSource.getRepository(Branch); - const branch = await branchRepository.findOne({ - where: { id: branchId, tenantId }, - select: ['id', 'type'], - }); - - if (!branch) { - res.status(404).json({ - success: false, - error: { - code: 'BRANCH_NOT_FOUND', - message: 'Branch not found', - }, - }); - return; - } - - // Check branch type capabilities - const capabilityMap: Record = { - pos: ['store', 'hybrid'], - inventory: ['store', 'warehouse', 'hybrid'], - ecommerce: ['store', 'warehouse', 'hybrid'], - }; - - const allowedTypes = capabilityMap[capability] || []; - if (!allowedTypes.includes(branch.type)) { - res.status(400).json({ - success: false, - error: { - code: 'BRANCH_CAPABILITY_ERROR', - message: `Branch does not support ${capability} operations`, - }, - }); - return; - } - - next(); - } catch (error) { - console.error('Error checking branch capability:', error); - res.status(500).json({ - success: false, - error: { - code: 'CAPABILITY_CHECK_ERROR', - message: 'Error checking branch capabilities', - }, - }); - } - }; -} diff --git a/backend/src/shared/middleware/index.ts b/backend/src/shared/middleware/index.ts deleted file mode 100644 index 4094e17..0000000 --- a/backend/src/shared/middleware/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './tenant.middleware'; -export * from './auth.middleware'; -export * from './branch.middleware'; diff --git a/backend/src/shared/middleware/tenant.middleware.ts b/backend/src/shared/middleware/tenant.middleware.ts deleted file mode 100644 index 3e9e1e9..0000000 --- a/backend/src/shared/middleware/tenant.middleware.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { AuthenticatedRequest, TenantContext } from '../types'; - -/** - * Middleware to extract and validate tenant context from request headers - * - * Expected header: X-Tenant-ID - */ -export function tenantMiddleware( - req: Request, - res: Response, - next: NextFunction -): void { - const tenantId = req.headers['x-tenant-id'] as string; - - if (!tenantId) { - res.status(400).json({ - success: false, - error: { - code: 'MISSING_TENANT', - message: 'X-Tenant-ID header is required', - }, - }); - return; - } - - // Validate UUID format - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - if (!uuidRegex.test(tenantId)) { - res.status(400).json({ - success: false, - error: { - code: 'INVALID_TENANT', - message: 'Invalid tenant ID format', - }, - }); - return; - } - - // Attach tenant context to request - const tenant: TenantContext = { - tenantId, - }; - - (req as AuthenticatedRequest).tenant = tenant; - next(); -} - -/** - * Optional tenant middleware - sets tenant if provided but doesn't require it - */ -export function optionalTenantMiddleware( - req: Request, - res: Response, - next: NextFunction -): void { - const tenantId = req.headers['x-tenant-id'] as string; - - if (tenantId) { - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - if (uuidRegex.test(tenantId)) { - (req as AuthenticatedRequest).tenant = { tenantId }; - } - } - - next(); -} diff --git a/backend/src/shared/services/base.service.ts b/backend/src/shared/services/base.service.ts deleted file mode 100644 index 7a2763c..0000000 --- a/backend/src/shared/services/base.service.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { - Repository, - FindOptionsWhere, - FindOptionsOrder, - FindOptionsRelations, - SelectQueryBuilder, - DeepPartial, - In, -} from 'typeorm'; -import { - PaginationParams, - PaginatedResult, - FilterCondition, - QueryOptions, - ServiceResult, -} from '../types'; - -export abstract class BaseService { - constructor(protected readonly repository: Repository) {} - - /** - * Find all entities for a tenant with optional pagination and filters - */ - async findAll( - tenantId: string, - options?: QueryOptions - ): Promise> { - const { - pagination = { page: 1, limit: 20 }, - filters = [], - search, - relations = [], - select, - } = options || {}; - - const queryBuilder = this.repository.createQueryBuilder('entity'); - - // Always filter by tenant - queryBuilder.where('entity.tenantId = :tenantId', { tenantId }); - - // Apply filters - this.applyFilters(queryBuilder, filters); - - // Apply search - if (search && search.term && search.fields.length > 0) { - const searchConditions = search.fields - .map((field, index) => `entity.${field} ILIKE :search${index}`) - .join(' OR '); - - const searchParams: Record = {}; - search.fields.forEach((_, index) => { - searchParams[`search${index}`] = `%${search.term}%`; - }); - - queryBuilder.andWhere(`(${searchConditions})`, searchParams); - } - - // Apply relations - relations.forEach((relation) => { - queryBuilder.leftJoinAndSelect(`entity.${relation}`, relation); - }); - - // Apply select - if (select && select.length > 0) { - queryBuilder.select(select.map((s) => `entity.${s}`)); - } - - // Get total count before pagination - const total = await queryBuilder.getCount(); - - // Apply sorting - const sortBy = pagination.sortBy || 'createdAt'; - const sortOrder = pagination.sortOrder || 'DESC'; - queryBuilder.orderBy(`entity.${sortBy}`, sortOrder); - - // Apply pagination - const skip = (pagination.page - 1) * pagination.limit; - queryBuilder.skip(skip).take(pagination.limit); - - const data = await queryBuilder.getMany(); - - const totalPages = Math.ceil(total / pagination.limit); - - return { - data, - pagination: { - page: pagination.page, - limit: pagination.limit, - total, - totalPages, - hasNext: pagination.page < totalPages, - hasPrev: pagination.page > 1, - }, - }; - } - - /** - * Find one entity by ID - */ - async findById( - tenantId: string, - id: string, - relations?: string[] - ): Promise { - const where = { tenantId, id } as FindOptionsWhere; - const relationsOption = relations - ? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations) - : undefined; - - return this.repository.findOne({ - where, - relations: relationsOption, - }); - } - - /** - * Find entities by a specific field - */ - async findBy( - tenantId: string, - field: keyof T, - value: any, - relations?: string[] - ): Promise { - const where = { tenantId, [field]: value } as FindOptionsWhere; - const relationsOption = relations - ? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations) - : undefined; - - return this.repository.find({ - where, - relations: relationsOption, - }); - } - - /** - * Find one entity by a specific field - */ - async findOneBy( - tenantId: string, - field: keyof T, - value: any, - relations?: string[] - ): Promise { - const where = { tenantId, [field]: value } as FindOptionsWhere; - const relationsOption = relations - ? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations) - : undefined; - - return this.repository.findOne({ - where, - relations: relationsOption, - }); - } - - /** - * Create a new entity - */ - async create( - tenantId: string, - data: DeepPartial, - userId?: string - ): Promise> { - try { - const entity = this.repository.create({ - ...data, - tenantId, - createdBy: userId, - updatedBy: userId, - } as DeepPartial); - - const saved = await this.repository.save(entity); - return { success: true, data: saved }; - } catch (error: any) { - return { - success: false, - error: { - code: 'CREATE_FAILED', - message: error.message || 'Failed to create entity', - details: error, - }, - }; - } - } - - /** - * Create multiple entities - */ - async createMany( - tenantId: string, - dataArray: DeepPartial[], - userId?: string - ): Promise> { - try { - const entities = dataArray.map((data) => - this.repository.create({ - ...data, - tenantId, - createdBy: userId, - updatedBy: userId, - } as DeepPartial) - ); - - const saved = await this.repository.save(entities); - return { success: true, data: saved }; - } catch (error: any) { - return { - success: false, - error: { - code: 'CREATE_MANY_FAILED', - message: error.message || 'Failed to create entities', - details: error, - }, - }; - } - } - - /** - * Update an entity - */ - async update( - tenantId: string, - id: string, - data: DeepPartial, - userId?: string - ): Promise> { - try { - const existing = await this.findById(tenantId, id); - if (!existing) { - return { - success: false, - error: { - code: 'NOT_FOUND', - message: 'Entity not found', - }, - }; - } - - const updated = this.repository.merge(existing, { - ...data, - updatedBy: userId, - } as DeepPartial); - - const saved = await this.repository.save(updated); - return { success: true, data: saved }; - } catch (error: any) { - return { - success: false, - error: { - code: 'UPDATE_FAILED', - message: error.message || 'Failed to update entity', - details: error, - }, - }; - } - } - - /** - * Delete an entity (soft delete if entity has deletedAt field) - */ - async delete(tenantId: string, id: string): Promise> { - try { - const existing = await this.findById(tenantId, id); - if (!existing) { - return { - success: false, - error: { - code: 'NOT_FOUND', - message: 'Entity not found', - }, - }; - } - - await this.repository.remove(existing); - return { success: true, data: true }; - } catch (error: any) { - return { - success: false, - error: { - code: 'DELETE_FAILED', - message: error.message || 'Failed to delete entity', - details: error, - }, - }; - } - } - - /** - * Delete multiple entities by IDs - */ - async deleteMany( - tenantId: string, - ids: string[] - ): Promise> { - try { - const result = await this.repository.delete({ - tenantId, - id: In(ids), - } as FindOptionsWhere); - - return { success: true, data: result.affected || 0 }; - } catch (error: any) { - return { - success: false, - error: { - code: 'DELETE_MANY_FAILED', - message: error.message || 'Failed to delete entities', - details: error, - }, - }; - } - } - - /** - * Check if entity exists - */ - async exists(tenantId: string, id: string): Promise { - const count = await this.repository.count({ - where: { tenantId, id } as FindOptionsWhere, - }); - return count > 0; - } - - /** - * Count entities matching criteria - */ - async count( - tenantId: string, - filters?: FilterCondition[] - ): Promise { - const queryBuilder = this.repository.createQueryBuilder('entity'); - queryBuilder.where('entity.tenantId = :tenantId', { tenantId }); - - if (filters) { - this.applyFilters(queryBuilder, filters); - } - - return queryBuilder.getCount(); - } - - /** - * Apply filter conditions to query builder - */ - protected applyFilters( - queryBuilder: SelectQueryBuilder, - filters: FilterCondition[] - ): void { - filters.forEach((filter, index) => { - const paramName = `filter${index}`; - - switch (filter.operator) { - case 'eq': - queryBuilder.andWhere(`entity.${filter.field} = :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'ne': - queryBuilder.andWhere(`entity.${filter.field} != :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'gt': - queryBuilder.andWhere(`entity.${filter.field} > :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'gte': - queryBuilder.andWhere(`entity.${filter.field} >= :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'lt': - queryBuilder.andWhere(`entity.${filter.field} < :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'lte': - queryBuilder.andWhere(`entity.${filter.field} <= :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'like': - queryBuilder.andWhere(`entity.${filter.field} LIKE :${paramName}`, { - [paramName]: `%${filter.value}%`, - }); - break; - case 'ilike': - queryBuilder.andWhere(`entity.${filter.field} ILIKE :${paramName}`, { - [paramName]: `%${filter.value}%`, - }); - break; - case 'in': - queryBuilder.andWhere(`entity.${filter.field} IN (:...${paramName})`, { - [paramName]: filter.value, - }); - break; - case 'between': - queryBuilder.andWhere( - `entity.${filter.field} BETWEEN :${paramName}Start AND :${paramName}End`, - { - [`${paramName}Start`]: filter.value[0], - [`${paramName}End`]: filter.value[1], - } - ); - break; - case 'isNull': - queryBuilder.andWhere(`entity.${filter.field} IS NULL`); - break; - case 'isNotNull': - queryBuilder.andWhere(`entity.${filter.field} IS NOT NULL`); - break; - } - }); - } -} diff --git a/backend/src/shared/types/index.ts b/backend/src/shared/types/index.ts deleted file mode 100644 index a62909d..0000000 --- a/backend/src/shared/types/index.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Request } from 'express'; - -// Tenant context -export interface TenantContext { - tenantId: string; - tenantName?: string; -} - -// Branch context -export interface BranchContext { - branchId: string; - branchCode?: string; - branchName?: string; - warehouseId?: string; -} - -// User context from JWT -export interface UserContext { - userId: string; - email: string; - name: string; - roles: string[]; - permissions?: string[]; -} - -// Extended Express Request with context -export interface AuthenticatedRequest extends Request { - tenant: TenantContext; - user: UserContext; - branch?: BranchContext; -} - -// Pagination -export interface PaginationParams { - page: number; - limit: number; - sortBy?: string; - sortOrder?: 'ASC' | 'DESC'; -} - -export interface PaginatedResult { - data: T[]; - pagination: { - page: number; - limit: number; - total: number; - totalPages: number; - hasNext: boolean; - hasPrev: boolean; - }; -} - -// Filter operators -export type FilterOperator = - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'like' - | 'ilike' - | 'in' - | 'between' - | 'isNull' - | 'isNotNull'; - -export interface FilterCondition { - field: string; - operator: FilterOperator; - value: any; -} - -// Query options for services -export interface QueryOptions { - pagination?: PaginationParams; - filters?: FilterCondition[]; - search?: { - fields: string[]; - term: string; - }; - relations?: string[]; - select?: string[]; -} - -// API Response types -export interface ApiResponse { - success: boolean; - data?: T; - error?: { - code: string; - message: string; - details?: any; - }; - meta?: { - timestamp: string; - requestId?: string; - }; -} - -// Service result type -export type ServiceResult = { - success: true; - data: T; -} | { - success: false; - error: { - code: string; - message: string; - details?: any; - }; -}; - -// Audit fields -export interface AuditFields { - createdAt: Date; - updatedAt: Date; - createdBy?: string; - updatedBy?: string; -} - -// Base entity interface -export interface BaseEntity extends AuditFields { - id: string; - tenantId: string; -} - -// Create/Update DTOs -export type CreateDTO = Omit; -export type UpdateDTO = Partial>; diff --git a/backend/src/shared/validation/common.schema.ts b/backend/src/shared/validation/common.schema.ts deleted file mode 100644 index a2bc675..0000000 --- a/backend/src/shared/validation/common.schema.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { z } from 'zod'; - -// Common validation patterns -export const uuidSchema = z.string().uuid('Invalid UUID format'); - -export const paginationSchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - limit: z.coerce.number().int().min(1).max(100).default(20), - sortBy: z.string().optional(), - sortOrder: z.enum(['ASC', 'DESC', 'asc', 'desc']).transform(v => v.toUpperCase()).optional(), -}); - -export const dateSchema = z.coerce.date(); - -export const dateRangeSchema = z.object({ - startDate: dateSchema, - endDate: dateSchema, -}).refine(data => data.startDate <= data.endDate, { - message: 'Start date must be before or equal to end date', -}); - -export const moneySchema = z.coerce.number().min(0).multipleOf(0.01); - -export const quantitySchema = z.coerce.number().min(0); - -export const percentSchema = z.coerce.number().min(0).max(100); - -export const emailSchema = z.string().email('Invalid email format'); - -export const phoneSchema = z.string().regex(/^[+]?[\d\s()-]{7,20}$/, 'Invalid phone format'); - -export const rfcSchema = z.string() - .regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z\d]{3}$/, 'Invalid RFC format') - .transform(v => v.toUpperCase()); - -export const postalCodeSchema = z.string().regex(/^\d{5}$/, 'Invalid postal code'); - -// Address schema -export const addressSchema = z.object({ - address1: z.string().min(1).max(255), - address2: z.string().max(255).optional(), - city: z.string().min(1).max(100), - state: z.string().min(1).max(100), - postalCode: postalCodeSchema, - country: z.string().length(3).default('MEX'), -}); - -// Shipping address with contact info -export const shippingAddressSchema = addressSchema.extend({ - firstName: z.string().min(1).max(100), - lastName: z.string().min(1).max(100), - company: z.string().max(200).optional(), - phone: phoneSchema, - instructions: z.string().max(500).optional(), -}); - -// Billing address with fiscal info -export const billingAddressSchema = addressSchema.extend({ - firstName: z.string().min(1).max(100), - lastName: z.string().min(1).max(100), - company: z.string().max(200).optional(), - rfc: rfcSchema.optional(), - phone: phoneSchema, -}); - -// Operating hours schema -export const operatingHoursSchema = z.object({ - monday: z.object({ open: z.string(), close: z.string() }).optional(), - tuesday: z.object({ open: z.string(), close: z.string() }).optional(), - wednesday: z.object({ open: z.string(), close: z.string() }).optional(), - thursday: z.object({ open: z.string(), close: z.string() }).optional(), - friday: z.object({ open: z.string(), close: z.string() }).optional(), - saturday: z.object({ open: z.string(), close: z.string() }).optional(), - sunday: z.object({ open: z.string(), close: z.string() }).optional(), -}).optional(); - -// Coordinates schema -export const coordinatesSchema = z.object({ - latitude: z.coerce.number().min(-90).max(90), - longitude: z.coerce.number().min(-180).max(180), -}); - -// ID params schema -export const idParamSchema = z.object({ - id: uuidSchema, -}); - -// Type exports -export type PaginationInput = z.infer; -export type AddressInput = z.infer; -export type ShippingAddressInput = z.infer; -export type BillingAddressInput = z.infer; diff --git a/backend/src/shared/validation/index.ts b/backend/src/shared/validation/index.ts deleted file mode 100644 index d258d65..0000000 --- a/backend/src/shared/validation/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './common.schema'; -export * from './validation.middleware'; diff --git a/backend/src/shared/validation/validation.middleware.ts b/backend/src/shared/validation/validation.middleware.ts deleted file mode 100644 index c8c02d7..0000000 --- a/backend/src/shared/validation/validation.middleware.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { ZodSchema, ZodError } from 'zod'; - -type RequestPart = 'body' | 'query' | 'params'; - -/** - * Format Zod errors into a user-friendly object - */ -function formatZodError(error: ZodError): Record { - const formatted: Record = {}; - - for (const issue of error.issues) { - const path = issue.path.join('.'); - if (!formatted[path]) { - formatted[path] = issue.message; - } - } - - return formatted; -} - -/** - * Create a validation middleware for a specific request part - */ -export function validate(schema: ZodSchema, part: RequestPart = 'body') { - return async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const data = req[part]; - const validated = await schema.parseAsync(data); - - // Replace the request part with validated/transformed data - (req as any)[part] = validated; - - next(); - } catch (error) { - if (error instanceof ZodError) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'Validation failed', - details: formatZodError(error), - }, - }); - return; - } - - next(error); - } - }; -} - -/** - * Validate request body - */ -export function validateBody(schema: ZodSchema) { - return validate(schema, 'body'); -} - -/** - * Validate query parameters - */ -export function validateQuery(schema: ZodSchema) { - return validate(schema, 'query'); -} - -/** - * Validate URL parameters - */ -export function validateParams(schema: ZodSchema) { - return validate(schema, 'params'); -} - -/** - * Validate multiple request parts at once - */ -export function validateRequest< - TBody = any, - TQuery = any, - TParams = any ->(schemas: { - body?: ZodSchema; - query?: ZodSchema; - params?: ZodSchema; -}) { - return async (req: Request, res: Response, next: NextFunction): Promise => { - const errors: Record> = {}; - - try { - if (schemas.params) { - req.params = await schemas.params.parseAsync(req.params); - } - } catch (error) { - if (error instanceof ZodError) { - errors.params = formatZodError(error); - } - } - - try { - if (schemas.query) { - (req as any).query = await schemas.query.parseAsync(req.query); - } - } catch (error) { - if (error instanceof ZodError) { - errors.query = formatZodError(error); - } - } - - try { - if (schemas.body) { - req.body = await schemas.body.parseAsync(req.body); - } - } catch (error) { - if (error instanceof ZodError) { - errors.body = formatZodError(error); - } - } - - if (Object.keys(errors).length > 0) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'Validation failed', - details: errors, - }, - }); - return; - } - - next(); - }; -} diff --git a/backend/tsconfig.json b/backend/tsconfig.json deleted file mode 100644 index 8b85f0f..0000000 --- a/backend/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "commonjs", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "strictPropertyInitialization": false, - "moduleResolution": "node", - "baseUrl": "./src", - "paths": { - "@config/*": ["config/*"], - "@modules/*": ["modules/*"], - "@shared/*": ["shared/*"] - } - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/database/HERENCIA-ERP-CORE.md b/database/HERENCIA-ERP-CORE.md deleted file mode 100644 index 37faa13..0000000 --- a/database/HERENCIA-ERP-CORE.md +++ /dev/null @@ -1,196 +0,0 @@ -# Herencia de Base de Datos - ERP Core -> Retail - -**Fecha:** 2025-12-08 -**Versión:** 1.0 -**Vertical:** Retail -**Nivel:** 2B.2 - ---- - -## RESUMEN - -La vertical de Retail hereda los schemas base del ERP Core y extiende con schemas específicos del dominio de punto de venta y comercio minorista. - -**Ubicación DDL Core:** `apps/erp-core/database/ddl/` - ---- - -## ARQUITECTURA DE HERENCIA - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ERP CORE (Base) │ -│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ -│ │ auth │ │ core │ │financial│ │inventory│ │ purchase │ │ -│ │ 26 tbl │ │ 12 tbl │ │ 15 tbl │ │ 15 tbl │ │ 8 tbl │ │ -│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ -│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ -│ │ sales │ │analytics│ │ system │ │ crm │ │ -│ │ 6 tbl │ │ 5 tbl │ │ 10 tbl │ │ 5 tbl │ │ -│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ -│ TOTAL: ~102 tablas heredadas │ -└─────────────────────────────────────────────────────────────────┘ - │ - │ HEREDA - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ RETAIL (Extensiones) │ -│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ -│ │ pos │ │ stores │ │ pricing │ │ -│ │ (punto venta) │ │ (sucursales) │ │ (promociones) │ │ -│ └───────────────┘ └───────────────┘ └───────────────┘ │ -│ EXTENSIONES: ~30 tablas (planificadas) │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## SCHEMAS HEREDADOS DEL CORE - -| Schema | Tablas | Uso en Retail | -|--------|--------|---------------| -| `auth` | 26 | Autenticación, usuarios por sucursal | -| `core` | 12 | Partners (clientes), catálogos | -| `financial` | 15 | Facturas, cuentas, caja | -| `inventory` | 15 | Inventario multi-sucursal | -| `purchase` | 8 | Compras a proveedores | -| `sales` | 6 | Ventas base | -| `crm` | 5 | Clientes frecuentes | -| `analytics` | 5 | Métricas de venta | -| `system` | 10 | Notificaciones | - -**Total heredado:** ~102 tablas - ---- - -## SCHEMAS ESPECÍFICOS DE RETAIL (Planificados) - -### 1. Schema `pos` (estimado 12+ tablas) - -**Propósito:** Punto de venta y operaciones de caja - -```sql --- Tablas principales planificadas: -pos.cash_registers -- Cajas registradoras -pos.cash_sessions -- Sesiones de caja -pos.pos_orders -- Tickets/ventas POS -pos.pos_order_lines -- Líneas de ticket -pos.payment_methods -- Métodos de pago -pos.cash_movements -- Movimientos de caja -pos.cash_counts -- Cortes de caja -pos.receipts -- Recibos -``` - -### 2. Schema `stores` (estimado 8+ tablas) - -**Propósito:** Gestión de sucursales - -```sql --- Tablas principales planificadas: -stores.branches -- Sucursales -stores.branch_inventory -- Inventario por sucursal -stores.transfers -- Transferencias entre sucursales -stores.transfer_lines -- Líneas de transferencia -stores.branch_employees -- Empleados por sucursal -``` - -### 3. Schema `pricing` (estimado 10+ tablas) - -**Propósito:** Precios y promociones - -```sql --- Extiende: sales schema del core -pricing.price_lists -- Listas de precios -pricing.promotions -- Promociones -pricing.discounts -- Descuentos -pricing.loyalty_programs -- Programas de lealtad -pricing.coupons -- Cupones -pricing.price_history -- Historial de precios -``` - ---- - -## SPECS DEL CORE APLICABLES - -**Documento detallado:** `orchestration/00-guidelines/HERENCIA-SPECS-CORE.md` - -### Correcciones de DDL Core (2025-12-08) - -El DDL del ERP-Core fue corregido para resolver FK inválidas: - -1. **stock_valuation_layers**: Campos `journal_entry_id` y `journal_entry_line_id` (antes `account_move_*`) -2. **stock_move_consume_rel**: Nueva tabla de trazabilidad (antes `move_line_consume_rel`) -3. **category_stock_accounts**: FK corregida a `core.product_categories` -4. **product_categories**: ALTERs ahora apuntan a schema `core` - -### SPECS Obligatorias - -| Spec Core | Aplicación en Retail | SP | Estado | -|-----------|---------------------|----:|--------| -| SPEC-SISTEMA-SECUENCIAS | Foliado de tickets y facturas | 8 | ✅ DDL LISTO | -| SPEC-VALORACION-INVENTARIO | Costeo de mercancía | 21 | ✅ DDL LISTO | -| SPEC-SEGURIDAD-API-KEYS-PERMISOS | Control de acceso por sucursal | 31 | ✅ DDL LISTO | -| SPEC-PRICING-RULES | Precios y promociones | 8 | PENDIENTE | -| SPEC-INVENTARIOS-CICLICOS | Conteos en sucursales | 13 | ✅ DDL LISTO | -| SPEC-TRAZABILIDAD-LOTES-SERIES | Productos con lote/serie | 13 | ✅ DDL LISTO | -| SPEC-MAIL-THREAD-TRACKING | Comunicación con clientes | 13 | PENDIENTE | -| SPEC-WIZARD-TRANSIENT-MODEL | Wizards de cierre de caja | 8 | PENDIENTE | - -### SPECS Opcionales - -| Spec Core | Decisión | Razón | -|-----------|----------|-------| -| SPEC-PORTAL-PROVEEDORES | EVALUAR | Para compras centralizadas | -| SPEC-TAREAS-RECURRENTES | EVALUAR | Para reorden automático | - -### SPECS No Aplican - -| Spec Core | Razón | -|-----------|-------| -| SPEC-INTEGRACION-CALENDAR | No requiere calendario de citas | -| SPEC-PROYECTOS-DEPENDENCIAS-BURNDOWN | No hay proyectos largos | -| SPEC-FIRMA-ELECTRONICA-NOM151 | No aplica para tickets POS | - ---- - -## ORDEN DE EJECUCIÓN DDL (Futuro) - -```bash -# PASO 1: Cargar ERP Core (base) -cd apps/erp-core/database -./scripts/reset-database.sh --force - -# PASO 2: Cargar extensiones de Retail -cd apps/verticales/retail/database -psql $DATABASE_URL -f init/00-extensions.sql -psql $DATABASE_URL -f init/01-create-schemas.sql -psql $DATABASE_URL -f init/02-pos-tables.sql -psql $DATABASE_URL -f init/03-stores-tables.sql -psql $DATABASE_URL -f init/04-pricing-tables.sql -``` - ---- - -## MAPEO DE NOMENCLATURA - -| Core | Retail | -|------|--------| -| `core.partners` | Clientes, proveedores | -| `inventory.products` | Productos de venta | -| `inventory.locations` | Almacenes de sucursal | -| `sales.sale_orders` | Base para POS orders | -| `financial.invoices` | Facturas de venta | - ---- - -## REFERENCIAS - -- ERP Core DDL: `apps/erp-core/database/ddl/` -- ERP Core README: `apps/erp-core/database/README.md` -- Directivas: `orchestration/directivas/` -- Inventarios: `orchestration/inventarios/` - ---- - -**Documento de herencia oficial** -**Última actualización:** 2025-12-08 diff --git a/database/README.md b/database/README.md deleted file mode 100644 index dff099d..0000000 --- a/database/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# Base de Datos - ERP Retail/POS - -## Resumen - -| Aspecto | Valor | -|---------|-------| -| **Schema principal** | `retail` | -| **Tablas específicas** | 16 | -| **ENUMs** | 6 | -| **Hereda de ERP-Core** | 144 tablas (12 schemas) | - -## Prerequisitos - -1. **ERP-Core instalado** con todos sus schemas -2. **Extensiones PostgreSQL**: pg_trgm - -## Orden de Ejecución DDL - -```bash -# 1. Instalar ERP-Core primero -cd apps/erp-core/database -./scripts/reset-database.sh - -# 2. Instalar extensión Retail -cd apps/verticales/retail/database -psql $DATABASE_URL -f init/00-extensions.sql -psql $DATABASE_URL -f init/01-create-schemas.sql -psql $DATABASE_URL -f init/02-rls-functions.sql -psql $DATABASE_URL -f init/03-retail-tables.sql -``` - -## Tablas Implementadas - -### Schema: retail (16 tablas) - -| Tabla | Módulo | Descripción | -|-------|--------|-------------| -| branches | RT-002 | Sucursales | -| cash_registers | RT-001 | Cajas registradoras | -| pos_sessions | RT-001 | Sesiones de POS | -| pos_orders | RT-001 | Ventas/Órdenes | -| pos_order_lines | RT-001 | Líneas de venta | -| pos_payments | RT-001 | Pagos (mixtos) | -| cash_movements | RT-001 | Entradas/salidas efectivo | -| branch_stock | RT-002 | Stock por sucursal | -| stock_transfers | RT-002 | Transferencias | -| stock_transfer_lines | RT-002 | Líneas de transferencia | -| product_barcodes | RT-003 | Códigos de barras | -| promotions | RT-003 | Promociones | -| promotion_products | RT-003 | Productos en promo | -| loyalty_programs | RT-004 | Programas fidelización | -| loyalty_cards | RT-004 | Tarjetas | -| loyalty_transactions | RT-004 | Transacciones puntos | - -## ENUMs - -| Enum | Valores | -|------|---------| -| pos_session_status | opening, open, closing, closed | -| pos_order_status | draft, paid, done, cancelled, refunded | -| payment_method | cash, card, transfer, credit, mixed | -| cash_movement_type | in, out | -| transfer_status | draft, pending, in_transit, received, cancelled | -| promotion_type | percentage, fixed_amount, buy_x_get_y, bundle | - -## Row Level Security - -Todas las tablas tienen RLS con: -```sql -tenant_id = current_setting('app.current_tenant_id', true)::UUID -``` - -## Consideraciones Especiales - -- **Operación offline**: POS puede operar sin conexión -- **Rendimiento**: <100ms por transacción -- **Hardware**: Integración con impresoras y lectores -- **CFDI 4.0**: Facturación en tiempo real - -## Referencias - -- [HERENCIA-ERP-CORE.md](./HERENCIA-ERP-CORE.md) -- [DATABASE_INVENTORY.yml](../orchestration/inventarios/DATABASE_INVENTORY.yml) diff --git a/database/init/00-extensions.sql b/database/init/00-extensions.sql deleted file mode 100644 index 87e9d45..0000000 --- a/database/init/00-extensions.sql +++ /dev/null @@ -1,22 +0,0 @@ --- ============================================================================ --- EXTENSIONES PostgreSQL - ERP Retail/POS --- ============================================================================ --- Versión: 1.0.0 --- Fecha: 2025-12-09 --- Prerequisito: ERP-Core debe estar instalado --- ============================================================================ - --- Verificar que ERP-Core esté instalado -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN - RAISE EXCEPTION 'ERP-Core no instalado. Ejecutar primero DDL de erp-core.'; - END IF; -END $$; - --- Extensión para búsqueda de texto (productos, códigos) -CREATE EXTENSION IF NOT EXISTS pg_trgm; - --- ============================================================================ --- FIN EXTENSIONES --- ============================================================================ diff --git a/database/init/01-create-schemas.sql b/database/init/01-create-schemas.sql deleted file mode 100644 index 56518b4..0000000 --- a/database/init/01-create-schemas.sql +++ /dev/null @@ -1,15 +0,0 @@ --- ============================================================================ --- SCHEMAS - ERP Retail/POS --- ============================================================================ --- Versión: 1.0.0 --- Fecha: 2025-12-09 --- ============================================================================ - --- Schema principal para operaciones de punto de venta -CREATE SCHEMA IF NOT EXISTS retail; - -COMMENT ON SCHEMA retail IS 'Schema para operaciones de punto de venta y retail'; - --- ============================================================================ --- FIN SCHEMAS --- ============================================================================ diff --git a/database/init/02-rls-functions.sql b/database/init/02-rls-functions.sql deleted file mode 100644 index e3085d4..0000000 --- a/database/init/02-rls-functions.sql +++ /dev/null @@ -1,30 +0,0 @@ --- ============================================================================ --- FUNCIONES RLS - ERP Retail/POS --- ============================================================================ --- Versión: 1.0.0 --- Fecha: 2025-12-09 --- Nota: Usa las funciones de contexto de ERP-Core (auth schema) --- ============================================================================ - --- Las funciones principales están en ERP-Core: --- auth.get_current_tenant_id() --- auth.get_current_user_id() --- auth.get_current_company_id() - --- Función para obtener sucursal actual del usuario (para POS) -CREATE OR REPLACE FUNCTION retail.get_current_branch_id() -RETURNS UUID AS $$ -BEGIN - RETURN current_setting('app.current_branch_id', true)::UUID; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$ LANGUAGE plpgsql STABLE; - -COMMENT ON FUNCTION retail.get_current_branch_id IS -'Obtiene el ID de la sucursal actual para operaciones POS'; - --- ============================================================================ --- FIN FUNCIONES RLS --- ============================================================================ diff --git a/database/init/03-retail-tables.sql b/database/init/03-retail-tables.sql deleted file mode 100644 index d2f8635..0000000 --- a/database/init/03-retail-tables.sql +++ /dev/null @@ -1,723 +0,0 @@ --- ============================================================================ --- TABLAS RETAIL/POS - ERP Retail --- ============================================================================ --- Módulos: RT-001 (POS), RT-002 (Inventario), RT-003 (Productos), RT-004 (Clientes) --- Versión: 1.0.0 --- Fecha: 2025-12-09 --- ============================================================================ --- PREREQUISITOS: --- 1. ERP-Core instalado (auth, core, inventory, sales, financial) --- 2. Schema retail creado --- ============================================================================ - --- ============================================================================ --- TYPES (ENUMs) --- ============================================================================ - -DO $$ BEGIN - CREATE TYPE retail.pos_session_status AS ENUM ( - 'opening', 'open', 'closing', 'closed' - ); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -DO $$ BEGIN - CREATE TYPE retail.pos_order_status AS ENUM ( - 'draft', 'paid', 'done', 'cancelled', 'refunded' - ); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -DO $$ BEGIN - CREATE TYPE retail.payment_method AS ENUM ( - 'cash', 'card', 'transfer', 'credit', 'mixed' - ); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -DO $$ BEGIN - CREATE TYPE retail.cash_movement_type AS ENUM ( - 'in', 'out' - ); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -DO $$ BEGIN - CREATE TYPE retail.transfer_status AS ENUM ( - 'draft', 'pending', 'in_transit', 'received', 'cancelled' - ); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -DO $$ BEGIN - CREATE TYPE retail.promotion_type AS ENUM ( - 'percentage', 'fixed_amount', 'buy_x_get_y', 'bundle' - ); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - --- ============================================================================ --- SUCURSALES Y CONFIGURACIÓN --- ============================================================================ - --- Tabla: branches (Sucursales) -CREATE TABLE IF NOT EXISTS retail.branches ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - company_id UUID REFERENCES auth.companies(id), - - -- Identificación - code VARCHAR(20) NOT NULL, - name VARCHAR(100) NOT NULL, - - -- Ubicación - address VARCHAR(255), - city VARCHAR(100), - state VARCHAR(100), - zip_code VARCHAR(10), - country VARCHAR(100) DEFAULT 'México', - latitude DECIMAL(10,8), - longitude DECIMAL(11,8), - - -- Contacto - phone VARCHAR(20), - email VARCHAR(255), - manager_id UUID REFERENCES auth.users(id), - - -- Configuración - warehouse_id UUID, -- FK a inventory.warehouses (ERP Core) - default_pricelist_id UUID, - timezone VARCHAR(50) DEFAULT 'America/Mexico_City', - - -- Control - is_active BOOLEAN NOT NULL DEFAULT TRUE, - opening_date DATE, - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMPTZ, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_branches_code UNIQUE (tenant_id, code) -); - --- Tabla: cash_registers (Cajas registradoras) -CREATE TABLE IF NOT EXISTS retail.cash_registers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - branch_id UUID NOT NULL REFERENCES retail.branches(id), - - -- Identificación - code VARCHAR(20) NOT NULL, - name VARCHAR(100) NOT NULL, - - -- Configuración - is_active BOOLEAN NOT NULL DEFAULT TRUE, - default_payment_method retail.payment_method DEFAULT 'cash', - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_cash_registers_code UNIQUE (tenant_id, branch_id, code) -); - --- ============================================================================ --- PUNTO DE VENTA (RT-001) --- ============================================================================ - --- Tabla: pos_sessions (Sesiones de POS) -CREATE TABLE IF NOT EXISTS retail.pos_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - branch_id UUID NOT NULL REFERENCES retail.branches(id), - cash_register_id UUID NOT NULL REFERENCES retail.cash_registers(id), - - -- Usuario - user_id UUID NOT NULL REFERENCES auth.users(id), - - -- Estado - status retail.pos_session_status NOT NULL DEFAULT 'opening', - - -- Apertura - opening_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), - opening_balance DECIMAL(14,2) NOT NULL DEFAULT 0, - - -- Cierre - closing_date TIMESTAMPTZ, - closing_balance DECIMAL(14,2), - closing_notes TEXT, - - -- Totales calculados - total_sales DECIMAL(14,2) DEFAULT 0, - total_refunds DECIMAL(14,2) DEFAULT 0, - total_cash_in DECIMAL(14,2) DEFAULT 0, - total_cash_out DECIMAL(14,2) DEFAULT 0, - total_card DECIMAL(14,2) DEFAULT 0, - total_transfer DECIMAL(14,2) DEFAULT 0, - - -- Diferencia - expected_balance DECIMAL(14,2), - difference DECIMAL(14,2), - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ, - updated_by UUID REFERENCES auth.users(id) -); - --- Tabla: pos_orders (Órdenes/Ventas de POS) -CREATE TABLE IF NOT EXISTS retail.pos_orders ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - session_id UUID NOT NULL REFERENCES retail.pos_sessions(id), - branch_id UUID NOT NULL REFERENCES retail.branches(id), - - -- Número de ticket - order_number VARCHAR(30) NOT NULL, - order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Cliente (opcional) - customer_id UUID, -- FK a core.partners (ERP Core) - customer_name VARCHAR(200), - - -- Estado - status retail.pos_order_status NOT NULL DEFAULT 'draft', - - -- Totales - subtotal DECIMAL(14,2) NOT NULL DEFAULT 0, - discount_amount DECIMAL(14,2) DEFAULT 0, - tax_amount DECIMAL(14,2) DEFAULT 0, - total DECIMAL(14,2) NOT NULL DEFAULT 0, - - -- Pago - payment_method retail.payment_method, - amount_paid DECIMAL(14,2) DEFAULT 0, - change_amount DECIMAL(14,2) DEFAULT 0, - - -- Facturación - requires_invoice BOOLEAN DEFAULT FALSE, - invoice_id UUID, -- FK a financial.invoices (ERP Core) - - -- Notas - notes TEXT, - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_pos_orders_number UNIQUE (tenant_id, order_number) -); - --- Tabla: pos_order_lines (Líneas de venta) -CREATE TABLE IF NOT EXISTS retail.pos_order_lines ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - order_id UUID NOT NULL REFERENCES retail.pos_orders(id) ON DELETE CASCADE, - - -- Producto - product_id UUID NOT NULL, -- FK a inventory.products (ERP Core) - product_name VARCHAR(255) NOT NULL, - barcode VARCHAR(50), - - -- Cantidades - quantity DECIMAL(12,4) NOT NULL, - unit_price DECIMAL(12,4) NOT NULL, - - -- Descuentos - discount_percent DECIMAL(5,2) DEFAULT 0, - discount_amount DECIMAL(12,2) DEFAULT 0, - - -- Totales - subtotal DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_price) STORED, - tax_amount DECIMAL(12,2) DEFAULT 0, - total DECIMAL(14,2) NOT NULL, - - -- Orden - sequence INTEGER DEFAULT 1, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id) -); - --- Tabla: pos_payments (Pagos de orden - para pagos mixtos) -CREATE TABLE IF NOT EXISTS retail.pos_payments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - order_id UUID NOT NULL REFERENCES retail.pos_orders(id) ON DELETE CASCADE, - - payment_method retail.payment_method NOT NULL, - amount DECIMAL(14,2) NOT NULL, - - -- Referencia (para tarjeta/transferencia) - reference VARCHAR(100), - card_last_four VARCHAR(4), - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id) -); - --- Tabla: cash_movements (Movimientos de efectivo) -CREATE TABLE IF NOT EXISTS retail.cash_movements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - session_id UUID NOT NULL REFERENCES retail.pos_sessions(id), - - -- Tipo y monto - movement_type retail.cash_movement_type NOT NULL, - amount DECIMAL(14,2) NOT NULL, - - -- Razón - reason VARCHAR(255) NOT NULL, - notes TEXT, - - -- Autorización - authorized_by UUID REFERENCES auth.users(id), - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id) -); - --- ============================================================================ --- INVENTARIO MULTI-SUCURSAL (RT-002) --- ============================================================================ - --- Tabla: branch_stock (Stock por sucursal) -CREATE TABLE IF NOT EXISTS retail.branch_stock ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - branch_id UUID NOT NULL REFERENCES retail.branches(id), - product_id UUID NOT NULL, -- FK a inventory.products (ERP Core) - - -- Cantidades - quantity_on_hand DECIMAL(12,4) NOT NULL DEFAULT 0, - quantity_reserved DECIMAL(12,4) DEFAULT 0, - quantity_available DECIMAL(12,4) GENERATED ALWAYS AS (quantity_on_hand - COALESCE(quantity_reserved, 0)) STORED, - - -- Límites - reorder_point DECIMAL(12,4), - max_stock DECIMAL(12,4), - - -- Control - last_count_date DATE, - last_count_qty DECIMAL(12,4), - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ, - - CONSTRAINT uq_branch_stock UNIQUE (branch_id, product_id) -); - --- Tabla: stock_transfers (Transferencias entre sucursales) -CREATE TABLE IF NOT EXISTS retail.stock_transfers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - -- Número - transfer_number VARCHAR(30) NOT NULL, - - -- Origen y destino - source_branch_id UUID NOT NULL REFERENCES retail.branches(id), - destination_branch_id UUID NOT NULL REFERENCES retail.branches(id), - - -- Estado - status retail.transfer_status NOT NULL DEFAULT 'draft', - - -- Fechas - request_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), - ship_date TIMESTAMPTZ, - receive_date TIMESTAMPTZ, - - -- Responsables - requested_by UUID NOT NULL REFERENCES auth.users(id), - shipped_by UUID REFERENCES auth.users(id), - received_by UUID REFERENCES auth.users(id), - - -- Notas - notes TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_stock_transfers_number UNIQUE (tenant_id, transfer_number), - CONSTRAINT chk_different_branches CHECK (source_branch_id != destination_branch_id) -); - --- Tabla: stock_transfer_lines (Líneas de transferencia) -CREATE TABLE IF NOT EXISTS retail.stock_transfer_lines ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - transfer_id UUID NOT NULL REFERENCES retail.stock_transfers(id) ON DELETE CASCADE, - - product_id UUID NOT NULL, -- FK a inventory.products (ERP Core) - quantity_requested DECIMAL(12,4) NOT NULL, - quantity_shipped DECIMAL(12,4), - quantity_received DECIMAL(12,4), - - notes TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id) -); - --- ============================================================================ --- PRODUCTOS RETAIL (RT-003) --- ============================================================================ - --- Tabla: product_barcodes (Códigos de barras múltiples) -CREATE TABLE IF NOT EXISTS retail.product_barcodes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - product_id UUID NOT NULL, -- FK a inventory.products (ERP Core) - - barcode VARCHAR(50) NOT NULL, - barcode_type VARCHAR(20) DEFAULT 'EAN13', -- EAN13, EAN8, UPC, CODE128, etc. - is_primary BOOLEAN DEFAULT FALSE, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_product_barcodes UNIQUE (tenant_id, barcode) -); - --- Tabla: promotions (Promociones) -CREATE TABLE IF NOT EXISTS retail.promotions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - code VARCHAR(30) NOT NULL, - name VARCHAR(100) NOT NULL, - description TEXT, - - -- Tipo de promoción - promotion_type retail.promotion_type NOT NULL, - discount_value DECIMAL(10,2), -- Porcentaje o monto fijo - - -- Vigencia - start_date TIMESTAMPTZ NOT NULL, - end_date TIMESTAMPTZ NOT NULL, - - -- Aplicación - applies_to_all BOOLEAN DEFAULT FALSE, - min_quantity DECIMAL(12,4), - min_amount DECIMAL(14,2), - - -- Sucursales (NULL = todas) - branch_ids UUID[], - - -- Control - is_active BOOLEAN NOT NULL DEFAULT TRUE, - max_uses INTEGER, - current_uses INTEGER DEFAULT 0, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_promotions_code UNIQUE (tenant_id, code), - CONSTRAINT chk_promotion_dates CHECK (end_date > start_date) -); - --- Tabla: promotion_products (Productos en promoción) -CREATE TABLE IF NOT EXISTS retail.promotion_products ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - promotion_id UUID NOT NULL REFERENCES retail.promotions(id) ON DELETE CASCADE, - product_id UUID NOT NULL, -- FK a inventory.products (ERP Core) - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ============================================================================ --- CLIENTES Y FIDELIZACIÓN (RT-004) --- ============================================================================ - --- Tabla: loyalty_programs (Programas de fidelización) -CREATE TABLE IF NOT EXISTS retail.loyalty_programs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - code VARCHAR(20) NOT NULL, - name VARCHAR(100) NOT NULL, - description TEXT, - - -- Configuración de puntos - points_per_currency DECIMAL(10,4) DEFAULT 1, -- Puntos por peso gastado - currency_per_point DECIMAL(10,4) DEFAULT 0.01, -- Valor del punto en pesos - min_points_redeem INTEGER DEFAULT 100, - - -- Vigencia - points_expiry_days INTEGER, -- NULL = no expiran - - is_active BOOLEAN NOT NULL DEFAULT TRUE, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_loyalty_programs_code UNIQUE (tenant_id, code) -); - --- Tabla: loyalty_cards (Tarjetas de fidelización) -CREATE TABLE IF NOT EXISTS retail.loyalty_cards ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - program_id UUID NOT NULL REFERENCES retail.loyalty_programs(id), - customer_id UUID NOT NULL, -- FK a core.partners (ERP Core) - - card_number VARCHAR(30) NOT NULL, - issue_date DATE NOT NULL DEFAULT CURRENT_DATE, - - -- Balance - points_balance INTEGER NOT NULL DEFAULT 0, - points_earned INTEGER NOT NULL DEFAULT 0, - points_redeemed INTEGER NOT NULL DEFAULT 0, - points_expired INTEGER NOT NULL DEFAULT 0, - - is_active BOOLEAN NOT NULL DEFAULT TRUE, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_loyalty_cards_number UNIQUE (tenant_id, card_number) -); - --- Tabla: loyalty_transactions (Transacciones de puntos) -CREATE TABLE IF NOT EXISTS retail.loyalty_transactions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - card_id UUID NOT NULL REFERENCES retail.loyalty_cards(id), - - -- Tipo - transaction_type VARCHAR(20) NOT NULL, -- earn, redeem, expire, adjust - points INTEGER NOT NULL, - - -- Referencia - order_id UUID REFERENCES retail.pos_orders(id), - description TEXT, - - -- Balance después de la transacción - balance_after INTEGER NOT NULL, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id) -); - --- ============================================================================ --- ÍNDICES --- ============================================================================ - --- Branches -CREATE INDEX IF NOT EXISTS idx_branches_tenant ON retail.branches(tenant_id); -CREATE INDEX IF NOT EXISTS idx_branches_company ON retail.branches(company_id); - --- Cash registers -CREATE INDEX IF NOT EXISTS idx_cash_registers_tenant ON retail.cash_registers(tenant_id); -CREATE INDEX IF NOT EXISTS idx_cash_registers_branch ON retail.cash_registers(branch_id); - --- POS sessions -CREATE INDEX IF NOT EXISTS idx_pos_sessions_tenant ON retail.pos_sessions(tenant_id); -CREATE INDEX IF NOT EXISTS idx_pos_sessions_branch ON retail.pos_sessions(branch_id); -CREATE INDEX IF NOT EXISTS idx_pos_sessions_user ON retail.pos_sessions(user_id); -CREATE INDEX IF NOT EXISTS idx_pos_sessions_status ON retail.pos_sessions(status); -CREATE INDEX IF NOT EXISTS idx_pos_sessions_date ON retail.pos_sessions(opening_date); - --- POS orders -CREATE INDEX IF NOT EXISTS idx_pos_orders_tenant ON retail.pos_orders(tenant_id); -CREATE INDEX IF NOT EXISTS idx_pos_orders_session ON retail.pos_orders(session_id); -CREATE INDEX IF NOT EXISTS idx_pos_orders_branch ON retail.pos_orders(branch_id); -CREATE INDEX IF NOT EXISTS idx_pos_orders_customer ON retail.pos_orders(customer_id); -CREATE INDEX IF NOT EXISTS idx_pos_orders_date ON retail.pos_orders(order_date); -CREATE INDEX IF NOT EXISTS idx_pos_orders_status ON retail.pos_orders(status); - --- POS order lines -CREATE INDEX IF NOT EXISTS idx_pos_order_lines_tenant ON retail.pos_order_lines(tenant_id); -CREATE INDEX IF NOT EXISTS idx_pos_order_lines_order ON retail.pos_order_lines(order_id); -CREATE INDEX IF NOT EXISTS idx_pos_order_lines_product ON retail.pos_order_lines(product_id); - --- POS payments -CREATE INDEX IF NOT EXISTS idx_pos_payments_tenant ON retail.pos_payments(tenant_id); -CREATE INDEX IF NOT EXISTS idx_pos_payments_order ON retail.pos_payments(order_id); - --- Cash movements -CREATE INDEX IF NOT EXISTS idx_cash_movements_tenant ON retail.cash_movements(tenant_id); -CREATE INDEX IF NOT EXISTS idx_cash_movements_session ON retail.cash_movements(session_id); - --- Branch stock -CREATE INDEX IF NOT EXISTS idx_branch_stock_tenant ON retail.branch_stock(tenant_id); -CREATE INDEX IF NOT EXISTS idx_branch_stock_branch ON retail.branch_stock(branch_id); -CREATE INDEX IF NOT EXISTS idx_branch_stock_product ON retail.branch_stock(product_id); - --- Stock transfers -CREATE INDEX IF NOT EXISTS idx_stock_transfers_tenant ON retail.stock_transfers(tenant_id); -CREATE INDEX IF NOT EXISTS idx_stock_transfers_source ON retail.stock_transfers(source_branch_id); -CREATE INDEX IF NOT EXISTS idx_stock_transfers_dest ON retail.stock_transfers(destination_branch_id); -CREATE INDEX IF NOT EXISTS idx_stock_transfers_status ON retail.stock_transfers(status); - --- Product barcodes -CREATE INDEX IF NOT EXISTS idx_product_barcodes_tenant ON retail.product_barcodes(tenant_id); -CREATE INDEX IF NOT EXISTS idx_product_barcodes_barcode ON retail.product_barcodes(barcode); -CREATE INDEX IF NOT EXISTS idx_product_barcodes_product ON retail.product_barcodes(product_id); - --- Promotions -CREATE INDEX IF NOT EXISTS idx_promotions_tenant ON retail.promotions(tenant_id); -CREATE INDEX IF NOT EXISTS idx_promotions_dates ON retail.promotions(start_date, end_date); -CREATE INDEX IF NOT EXISTS idx_promotions_active ON retail.promotions(is_active); - --- Loyalty -CREATE INDEX IF NOT EXISTS idx_loyalty_cards_tenant ON retail.loyalty_cards(tenant_id); -CREATE INDEX IF NOT EXISTS idx_loyalty_cards_customer ON retail.loyalty_cards(customer_id); -CREATE INDEX IF NOT EXISTS idx_loyalty_transactions_tenant ON retail.loyalty_transactions(tenant_id); -CREATE INDEX IF NOT EXISTS idx_loyalty_transactions_card ON retail.loyalty_transactions(card_id); - --- ============================================================================ --- ROW LEVEL SECURITY --- ============================================================================ - -ALTER TABLE retail.branches ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.cash_registers ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.pos_sessions ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.pos_orders ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.pos_order_lines ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.pos_payments ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.cash_movements ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.branch_stock ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.stock_transfers ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.stock_transfer_lines ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.product_barcodes ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.promotions ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.promotion_products ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.loyalty_programs ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.loyalty_cards ENABLE ROW LEVEL SECURITY; -ALTER TABLE retail.loyalty_transactions ENABLE ROW LEVEL SECURITY; - --- Políticas de aislamiento por tenant -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_branches ON retail.branches; - CREATE POLICY tenant_isolation_branches ON retail.branches - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_cash_registers ON retail.cash_registers; - CREATE POLICY tenant_isolation_cash_registers ON retail.cash_registers - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_pos_sessions ON retail.pos_sessions; - CREATE POLICY tenant_isolation_pos_sessions ON retail.pos_sessions - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_pos_orders ON retail.pos_orders; - CREATE POLICY tenant_isolation_pos_orders ON retail.pos_orders - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_pos_order_lines ON retail.pos_order_lines; - CREATE POLICY tenant_isolation_pos_order_lines ON retail.pos_order_lines - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_pos_payments ON retail.pos_payments; - CREATE POLICY tenant_isolation_pos_payments ON retail.pos_payments - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_cash_movements ON retail.cash_movements; - CREATE POLICY tenant_isolation_cash_movements ON retail.cash_movements - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_branch_stock ON retail.branch_stock; - CREATE POLICY tenant_isolation_branch_stock ON retail.branch_stock - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_stock_transfers ON retail.stock_transfers; - CREATE POLICY tenant_isolation_stock_transfers ON retail.stock_transfers - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_stock_transfer_lines ON retail.stock_transfer_lines; - CREATE POLICY tenant_isolation_stock_transfer_lines ON retail.stock_transfer_lines - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_product_barcodes ON retail.product_barcodes; - CREATE POLICY tenant_isolation_product_barcodes ON retail.product_barcodes - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_promotions ON retail.promotions; - CREATE POLICY tenant_isolation_promotions ON retail.promotions - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_promotion_products ON retail.promotion_products; - CREATE POLICY tenant_isolation_promotion_products ON retail.promotion_products - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_loyalty_programs ON retail.loyalty_programs; - CREATE POLICY tenant_isolation_loyalty_programs ON retail.loyalty_programs - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_loyalty_cards ON retail.loyalty_cards; - CREATE POLICY tenant_isolation_loyalty_cards ON retail.loyalty_cards - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - -DO $$ BEGIN - DROP POLICY IF EXISTS tenant_isolation_loyalty_transactions ON retail.loyalty_transactions; - CREATE POLICY tenant_isolation_loyalty_transactions ON retail.loyalty_transactions - FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -EXCEPTION WHEN undefined_object THEN NULL; END $$; - --- ============================================================================ --- COMENTARIOS --- ============================================================================ - -COMMENT ON TABLE retail.branches IS 'Sucursales de la empresa'; -COMMENT ON TABLE retail.cash_registers IS 'Cajas registradoras por sucursal'; -COMMENT ON TABLE retail.pos_sessions IS 'Sesiones de punto de venta'; -COMMENT ON TABLE retail.pos_orders IS 'Órdenes/Ventas de punto de venta'; -COMMENT ON TABLE retail.pos_order_lines IS 'Líneas de venta'; -COMMENT ON TABLE retail.pos_payments IS 'Pagos de orden (para pagos mixtos)'; -COMMENT ON TABLE retail.cash_movements IS 'Entradas/salidas de efectivo'; -COMMENT ON TABLE retail.branch_stock IS 'Stock por sucursal'; -COMMENT ON TABLE retail.stock_transfers IS 'Transferencias entre sucursales'; -COMMENT ON TABLE retail.stock_transfer_lines IS 'Líneas de transferencia'; -COMMENT ON TABLE retail.product_barcodes IS 'Códigos de barras múltiples por producto'; -COMMENT ON TABLE retail.promotions IS 'Promociones y descuentos'; -COMMENT ON TABLE retail.promotion_products IS 'Productos en promoción'; -COMMENT ON TABLE retail.loyalty_programs IS 'Programas de fidelización'; -COMMENT ON TABLE retail.loyalty_cards IS 'Tarjetas de fidelización'; -COMMENT ON TABLE retail.loyalty_transactions IS 'Transacciones de puntos'; - --- ============================================================================ --- FIN TABLAS RETAIL --- Total: 16 tablas, 6 ENUMs --- ============================================================================