commit 44ce9194c2f1b8494d40b5a50430be799a79987c Author: rckrdmrd Date: Sun Jan 4 07:04:02 2026 -0600 Initial commit - erp-retail-backend diff --git a/docs/SPRINT-4-SUMMARY.md b/docs/SPRINT-4-SUMMARY.md new file mode 100644 index 0000000..976c81c --- /dev/null +++ b/docs/SPRINT-4-SUMMARY.md @@ -0,0 +1,225 @@ +# 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/docs/SPRINT-5-SUMMARY.md b/docs/SPRINT-5-SUMMARY.md new file mode 100644 index 0000000..dc2e80c --- /dev/null +++ b/docs/SPRINT-5-SUMMARY.md @@ -0,0 +1,232 @@ +# 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/docs/SPRINT-6-SUMMARY.md b/docs/SPRINT-6-SUMMARY.md new file mode 100644 index 0000000..9f8ade8 --- /dev/null +++ b/docs/SPRINT-6-SUMMARY.md @@ -0,0 +1,216 @@ +# 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/package.json b/package.json new file mode 100644 index 0000000..8532620 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "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/src/app.ts b/src/app.ts new file mode 100644 index 0000000..c958da4 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,120 @@ +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/src/config/database.ts b/src/config/database.ts new file mode 100644 index 0000000..f70a136 --- /dev/null +++ b/src/config/database.ts @@ -0,0 +1,102 @@ +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/src/config/typeorm.ts b/src/config/typeorm.ts new file mode 100644 index 0000000..3379dc5 --- /dev/null +++ b/src/config/typeorm.ts @@ -0,0 +1,144 @@ +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/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8235774 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,76 @@ +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/src/modules/branches/controllers/branch.controller.ts b/src/modules/branches/controllers/branch.controller.ts new file mode 100644 index 0000000..5e9a849 --- /dev/null +++ b/src/modules/branches/controllers/branch.controller.ts @@ -0,0 +1,236 @@ +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/src/modules/branches/entities/branch-user.entity.ts b/src/modules/branches/entities/branch-user.entity.ts new file mode 100644 index 0000000..69df3f9 --- /dev/null +++ b/src/modules/branches/entities/branch-user.entity.ts @@ -0,0 +1,122 @@ +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/src/modules/branches/entities/branch.entity.ts b/src/modules/branches/entities/branch.entity.ts new file mode 100644 index 0000000..bb7096d --- /dev/null +++ b/src/modules/branches/entities/branch.entity.ts @@ -0,0 +1,148 @@ +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/src/modules/branches/entities/cash-register.entity.ts b/src/modules/branches/entities/cash-register.entity.ts new file mode 100644 index 0000000..faf8134 --- /dev/null +++ b/src/modules/branches/entities/cash-register.entity.ts @@ -0,0 +1,147 @@ +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/src/modules/branches/entities/index.ts b/src/modules/branches/entities/index.ts new file mode 100644 index 0000000..8a15505 --- /dev/null +++ b/src/modules/branches/entities/index.ts @@ -0,0 +1,3 @@ +export * from './branch.entity'; +export * from './cash-register.entity'; +export * from './branch-user.entity'; diff --git a/src/modules/branches/index.ts b/src/modules/branches/index.ts new file mode 100644 index 0000000..be64034 --- /dev/null +++ b/src/modules/branches/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './services'; +export { default as branchRoutes } from './routes/branch.routes'; diff --git a/src/modules/branches/routes/branch.routes.ts b/src/modules/branches/routes/branch.routes.ts new file mode 100644 index 0000000..0128cd7 --- /dev/null +++ b/src/modules/branches/routes/branch.routes.ts @@ -0,0 +1,48 @@ +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/src/modules/branches/services/branch.service.ts b/src/modules/branches/services/branch.service.ts new file mode 100644 index 0000000..b5652b3 --- /dev/null +++ b/src/modules/branches/services/branch.service.ts @@ -0,0 +1,243 @@ +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/src/modules/branches/services/index.ts b/src/modules/branches/services/index.ts new file mode 100644 index 0000000..ef1d184 --- /dev/null +++ b/src/modules/branches/services/index.ts @@ -0,0 +1 @@ +export * from './branch.service'; diff --git a/src/modules/branches/validation/branch.schema.ts b/src/modules/branches/validation/branch.schema.ts new file mode 100644 index 0000000..cedad78 --- /dev/null +++ b/src/modules/branches/validation/branch.schema.ts @@ -0,0 +1,82 @@ +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/src/modules/cash/controllers/cash.controller.ts b/src/modules/cash/controllers/cash.controller.ts new file mode 100644 index 0000000..eee370c --- /dev/null +++ b/src/modules/cash/controllers/cash.controller.ts @@ -0,0 +1,330 @@ +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/src/modules/cash/entities/cash-closing.entity.ts b/src/modules/cash/entities/cash-closing.entity.ts new file mode 100644 index 0000000..48899fe --- /dev/null +++ b/src/modules/cash/entities/cash-closing.entity.ts @@ -0,0 +1,205 @@ +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/src/modules/cash/entities/cash-count.entity.ts b/src/modules/cash/entities/cash-count.entity.ts new file mode 100644 index 0000000..fca51ab --- /dev/null +++ b/src/modules/cash/entities/cash-count.entity.ts @@ -0,0 +1,78 @@ +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/src/modules/cash/entities/cash-movement.entity.ts b/src/modules/cash/entities/cash-movement.entity.ts new file mode 100644 index 0000000..c6c30ef --- /dev/null +++ b/src/modules/cash/entities/cash-movement.entity.ts @@ -0,0 +1,152 @@ +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/src/modules/cash/entities/index.ts b/src/modules/cash/entities/index.ts new file mode 100644 index 0000000..d1b8e43 --- /dev/null +++ b/src/modules/cash/entities/index.ts @@ -0,0 +1,3 @@ +export * from './cash-movement.entity'; +export * from './cash-closing.entity'; +export * from './cash-count.entity'; diff --git a/src/modules/cash/index.ts b/src/modules/cash/index.ts new file mode 100644 index 0000000..43cf4c8 --- /dev/null +++ b/src/modules/cash/index.ts @@ -0,0 +1,5 @@ +export * from './entities'; +export * from './services'; +export * from './controllers/cash.controller'; +export * from './routes/cash.routes'; +export * from './validation/cash.schema'; diff --git a/src/modules/cash/routes/cash.routes.ts b/src/modules/cash/routes/cash.routes.ts new file mode 100644 index 0000000..0b8a67b --- /dev/null +++ b/src/modules/cash/routes/cash.routes.ts @@ -0,0 +1,155 @@ +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/src/modules/cash/services/cash-closing.service.ts b/src/modules/cash/services/cash-closing.service.ts new file mode 100644 index 0000000..df32521 --- /dev/null +++ b/src/modules/cash/services/cash-closing.service.ts @@ -0,0 +1,568 @@ +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/src/modules/cash/services/cash-movement.service.ts b/src/modules/cash/services/cash-movement.service.ts new file mode 100644 index 0000000..c1ec2dd --- /dev/null +++ b/src/modules/cash/services/cash-movement.service.ts @@ -0,0 +1,398 @@ +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/src/modules/cash/services/index.ts b/src/modules/cash/services/index.ts new file mode 100644 index 0000000..4f1220e --- /dev/null +++ b/src/modules/cash/services/index.ts @@ -0,0 +1,2 @@ +export * from './cash-movement.service'; +export * from './cash-closing.service'; diff --git a/src/modules/cash/validation/cash.schema.ts b/src/modules/cash/validation/cash.schema.ts new file mode 100644 index 0000000..e702154 --- /dev/null +++ b/src/modules/cash/validation/cash.schema.ts @@ -0,0 +1,160 @@ +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/src/modules/customers/controllers/loyalty.controller.ts b/src/modules/customers/controllers/loyalty.controller.ts new file mode 100644 index 0000000..409b078 --- /dev/null +++ b/src/modules/customers/controllers/loyalty.controller.ts @@ -0,0 +1,222 @@ +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/src/modules/customers/entities/customer-membership.entity.ts b/src/modules/customers/entities/customer-membership.entity.ts new file mode 100644 index 0000000..75ee66a --- /dev/null +++ b/src/modules/customers/entities/customer-membership.entity.ts @@ -0,0 +1,161 @@ +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/src/modules/customers/entities/index.ts b/src/modules/customers/entities/index.ts new file mode 100644 index 0000000..f0b523e --- /dev/null +++ b/src/modules/customers/entities/index.ts @@ -0,0 +1,4 @@ +export * from './loyalty-program.entity'; +export * from './membership-level.entity'; +export * from './loyalty-transaction.entity'; +export * from './customer-membership.entity'; diff --git a/src/modules/customers/entities/loyalty-program.entity.ts b/src/modules/customers/entities/loyalty-program.entity.ts new file mode 100644 index 0000000..d783728 --- /dev/null +++ b/src/modules/customers/entities/loyalty-program.entity.ts @@ -0,0 +1,161 @@ +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/src/modules/customers/entities/loyalty-transaction.entity.ts b/src/modules/customers/entities/loyalty-transaction.entity.ts new file mode 100644 index 0000000..93caf3d --- /dev/null +++ b/src/modules/customers/entities/loyalty-transaction.entity.ts @@ -0,0 +1,144 @@ +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/src/modules/customers/entities/membership-level.entity.ts b/src/modules/customers/entities/membership-level.entity.ts new file mode 100644 index 0000000..fb18d57 --- /dev/null +++ b/src/modules/customers/entities/membership-level.entity.ts @@ -0,0 +1,135 @@ +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/src/modules/customers/index.ts b/src/modules/customers/index.ts new file mode 100644 index 0000000..1433b71 --- /dev/null +++ b/src/modules/customers/index.ts @@ -0,0 +1,5 @@ +export * from './entities'; +export * from './services'; +export * from './controllers/loyalty.controller'; +export * from './routes/loyalty.routes'; +export * from './validation/customers.schema'; diff --git a/src/modules/customers/routes/loyalty.routes.ts b/src/modules/customers/routes/loyalty.routes.ts new file mode 100644 index 0000000..070dda8 --- /dev/null +++ b/src/modules/customers/routes/loyalty.routes.ts @@ -0,0 +1,113 @@ +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/src/modules/customers/services/index.ts b/src/modules/customers/services/index.ts new file mode 100644 index 0000000..14ac4dc --- /dev/null +++ b/src/modules/customers/services/index.ts @@ -0,0 +1 @@ +export * from './loyalty.service'; diff --git a/src/modules/customers/services/loyalty.service.ts b/src/modules/customers/services/loyalty.service.ts new file mode 100644 index 0000000..4cbb3a5 --- /dev/null +++ b/src/modules/customers/services/loyalty.service.ts @@ -0,0 +1,842 @@ +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/src/modules/customers/validation/customers.schema.ts b/src/modules/customers/validation/customers.schema.ts new file mode 100644 index 0000000..7052b96 --- /dev/null +++ b/src/modules/customers/validation/customers.schema.ts @@ -0,0 +1,179 @@ +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/src/modules/ecommerce/entities/cart-item.entity.ts b/src/modules/ecommerce/entities/cart-item.entity.ts new file mode 100644 index 0000000..300d074 --- /dev/null +++ b/src/modules/ecommerce/entities/cart-item.entity.ts @@ -0,0 +1,156 @@ +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/src/modules/ecommerce/entities/cart.entity.ts b/src/modules/ecommerce/entities/cart.entity.ts new file mode 100644 index 0000000..9efa479 --- /dev/null +++ b/src/modules/ecommerce/entities/cart.entity.ts @@ -0,0 +1,210 @@ +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/src/modules/ecommerce/entities/ecommerce-order-line.entity.ts b/src/modules/ecommerce/entities/ecommerce-order-line.entity.ts new file mode 100644 index 0000000..bf187ce --- /dev/null +++ b/src/modules/ecommerce/entities/ecommerce-order-line.entity.ts @@ -0,0 +1,198 @@ +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/src/modules/ecommerce/entities/ecommerce-order.entity.ts b/src/modules/ecommerce/entities/ecommerce-order.entity.ts new file mode 100644 index 0000000..21fb510 --- /dev/null +++ b/src/modules/ecommerce/entities/ecommerce-order.entity.ts @@ -0,0 +1,309 @@ +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/src/modules/ecommerce/entities/index.ts b/src/modules/ecommerce/entities/index.ts new file mode 100644 index 0000000..a61a16e --- /dev/null +++ b/src/modules/ecommerce/entities/index.ts @@ -0,0 +1,5 @@ +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/src/modules/ecommerce/entities/shipping-rate.entity.ts b/src/modules/ecommerce/entities/shipping-rate.entity.ts new file mode 100644 index 0000000..597e2b5 --- /dev/null +++ b/src/modules/ecommerce/entities/shipping-rate.entity.ts @@ -0,0 +1,182 @@ +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/src/modules/inventory/controllers/inventory.controller.ts b/src/modules/inventory/controllers/inventory.controller.ts new file mode 100644 index 0000000..9007143 --- /dev/null +++ b/src/modules/inventory/controllers/inventory.controller.ts @@ -0,0 +1,401 @@ +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/src/modules/inventory/entities/index.ts b/src/modules/inventory/entities/index.ts new file mode 100644 index 0000000..8ce403e --- /dev/null +++ b/src/modules/inventory/entities/index.ts @@ -0,0 +1,4 @@ +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/src/modules/inventory/entities/stock-adjustment-line.entity.ts b/src/modules/inventory/entities/stock-adjustment-line.entity.ts new file mode 100644 index 0000000..f4319b8 --- /dev/null +++ b/src/modules/inventory/entities/stock-adjustment-line.entity.ts @@ -0,0 +1,120 @@ +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/src/modules/inventory/entities/stock-adjustment.entity.ts b/src/modules/inventory/entities/stock-adjustment.entity.ts new file mode 100644 index 0000000..7ff62d5 --- /dev/null +++ b/src/modules/inventory/entities/stock-adjustment.entity.ts @@ -0,0 +1,169 @@ +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/src/modules/inventory/entities/stock-transfer-line.entity.ts b/src/modules/inventory/entities/stock-transfer-line.entity.ts new file mode 100644 index 0000000..5b313d2 --- /dev/null +++ b/src/modules/inventory/entities/stock-transfer-line.entity.ts @@ -0,0 +1,144 @@ +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/src/modules/inventory/entities/stock-transfer.entity.ts b/src/modules/inventory/entities/stock-transfer.entity.ts new file mode 100644 index 0000000..dbaf3d7 --- /dev/null +++ b/src/modules/inventory/entities/stock-transfer.entity.ts @@ -0,0 +1,173 @@ +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/src/modules/inventory/index.ts b/src/modules/inventory/index.ts new file mode 100644 index 0000000..4dd6d0e --- /dev/null +++ b/src/modules/inventory/index.ts @@ -0,0 +1,5 @@ +export * from './entities'; +export * from './services'; +export * from './controllers/inventory.controller'; +export * from './routes/inventory.routes'; +export * from './validation/inventory.schema'; diff --git a/src/modules/inventory/routes/inventory.routes.ts b/src/modules/inventory/routes/inventory.routes.ts new file mode 100644 index 0000000..71d8dfe --- /dev/null +++ b/src/modules/inventory/routes/inventory.routes.ts @@ -0,0 +1,179 @@ +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/src/modules/inventory/services/index.ts b/src/modules/inventory/services/index.ts new file mode 100644 index 0000000..d225f9b --- /dev/null +++ b/src/modules/inventory/services/index.ts @@ -0,0 +1,2 @@ +export * from './stock-transfer.service'; +export * from './stock-adjustment.service'; diff --git a/src/modules/inventory/services/stock-adjustment.service.ts b/src/modules/inventory/services/stock-adjustment.service.ts new file mode 100644 index 0000000..73c961d --- /dev/null +++ b/src/modules/inventory/services/stock-adjustment.service.ts @@ -0,0 +1,630 @@ +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/src/modules/inventory/services/stock-transfer.service.ts b/src/modules/inventory/services/stock-transfer.service.ts new file mode 100644 index 0000000..aeaf13e --- /dev/null +++ b/src/modules/inventory/services/stock-transfer.service.ts @@ -0,0 +1,591 @@ +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/src/modules/inventory/validation/inventory.schema.ts b/src/modules/inventory/validation/inventory.schema.ts new file mode 100644 index 0000000..44732f1 --- /dev/null +++ b/src/modules/inventory/validation/inventory.schema.ts @@ -0,0 +1,200 @@ +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/src/modules/invoicing/controllers/cfdi.controller.ts b/src/modules/invoicing/controllers/cfdi.controller.ts new file mode 100644 index 0000000..75d48cc --- /dev/null +++ b/src/modules/invoicing/controllers/cfdi.controller.ts @@ -0,0 +1,742 @@ +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/src/modules/invoicing/controllers/index.ts b/src/modules/invoicing/controllers/index.ts new file mode 100644 index 0000000..a78f683 --- /dev/null +++ b/src/modules/invoicing/controllers/index.ts @@ -0,0 +1 @@ +export * from './cfdi.controller'; diff --git a/src/modules/invoicing/entities/cfdi-config.entity.ts b/src/modules/invoicing/entities/cfdi-config.entity.ts new file mode 100644 index 0000000..40d8e61 --- /dev/null +++ b/src/modules/invoicing/entities/cfdi-config.entity.ts @@ -0,0 +1,199 @@ +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/src/modules/invoicing/entities/cfdi.entity.ts b/src/modules/invoicing/entities/cfdi.entity.ts new file mode 100644 index 0000000..356779e --- /dev/null +++ b/src/modules/invoicing/entities/cfdi.entity.ts @@ -0,0 +1,294 @@ +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/src/modules/invoicing/entities/index.ts b/src/modules/invoicing/entities/index.ts new file mode 100644 index 0000000..c28478c --- /dev/null +++ b/src/modules/invoicing/entities/index.ts @@ -0,0 +1,2 @@ +export * from './cfdi-config.entity'; +export * from './cfdi.entity'; diff --git a/src/modules/invoicing/index.ts b/src/modules/invoicing/index.ts new file mode 100644 index 0000000..fd9152d --- /dev/null +++ b/src/modules/invoicing/index.ts @@ -0,0 +1,14 @@ +// Services +export * from './services'; + +// Controllers +export * from './controllers'; + +// Routes +export * from './routes'; + +// Validation +export * from './validation'; + +// Entities +export * from './entities'; diff --git a/src/modules/invoicing/routes/cfdi.routes.ts b/src/modules/invoicing/routes/cfdi.routes.ts new file mode 100644 index 0000000..45302e0 --- /dev/null +++ b/src/modules/invoicing/routes/cfdi.routes.ts @@ -0,0 +1,222 @@ +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/src/modules/invoicing/routes/index.ts b/src/modules/invoicing/routes/index.ts new file mode 100644 index 0000000..240ad01 --- /dev/null +++ b/src/modules/invoicing/routes/index.ts @@ -0,0 +1 @@ +export { default as cfdiRoutes } from './cfdi.routes'; diff --git a/src/modules/invoicing/services/cfdi-builder.service.ts b/src/modules/invoicing/services/cfdi-builder.service.ts new file mode 100644 index 0000000..d83f3ef --- /dev/null +++ b/src/modules/invoicing/services/cfdi-builder.service.ts @@ -0,0 +1,507 @@ +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/src/modules/invoicing/services/cfdi.service.ts b/src/modules/invoicing/services/cfdi.service.ts new file mode 100644 index 0000000..f64878a --- /dev/null +++ b/src/modules/invoicing/services/cfdi.service.ts @@ -0,0 +1,815 @@ +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/src/modules/invoicing/services/index.ts b/src/modules/invoicing/services/index.ts new file mode 100644 index 0000000..aa74663 --- /dev/null +++ b/src/modules/invoicing/services/index.ts @@ -0,0 +1,4 @@ +export * from './cfdi.service'; +export * from './cfdi-builder.service'; +export * from './xml.service'; +export * from './pac.service'; diff --git a/src/modules/invoicing/services/pac.service.ts b/src/modules/invoicing/services/pac.service.ts new file mode 100644 index 0000000..fcbbede --- /dev/null +++ b/src/modules/invoicing/services/pac.service.ts @@ -0,0 +1,755 @@ +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/src/modules/invoicing/services/xml.service.ts b/src/modules/invoicing/services/xml.service.ts new file mode 100644 index 0000000..2321136 --- /dev/null +++ b/src/modules/invoicing/services/xml.service.ts @@ -0,0 +1,534 @@ +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/src/modules/invoicing/validation/cfdi.schema.ts b/src/modules/invoicing/validation/cfdi.schema.ts new file mode 100644 index 0000000..94f14c6 --- /dev/null +++ b/src/modules/invoicing/validation/cfdi.schema.ts @@ -0,0 +1,245 @@ +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/src/modules/invoicing/validation/index.ts b/src/modules/invoicing/validation/index.ts new file mode 100644 index 0000000..390a2e6 --- /dev/null +++ b/src/modules/invoicing/validation/index.ts @@ -0,0 +1 @@ +export * from './cfdi.schema'; diff --git a/src/modules/pos/controllers/pos.controller.ts b/src/modules/pos/controllers/pos.controller.ts new file mode 100644 index 0000000..1f1d5d3 --- /dev/null +++ b/src/modules/pos/controllers/pos.controller.ts @@ -0,0 +1,605 @@ +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/src/modules/pos/entities/index.ts b/src/modules/pos/entities/index.ts new file mode 100644 index 0000000..d489fed --- /dev/null +++ b/src/modules/pos/entities/index.ts @@ -0,0 +1,4 @@ +export * from './pos-session.entity'; +export * from './pos-order.entity'; +export * from './pos-order-line.entity'; +export * from './pos-payment.entity'; diff --git a/src/modules/pos/entities/pos-order-line.entity.ts b/src/modules/pos/entities/pos-order-line.entity.ts new file mode 100644 index 0000000..9f63e55 --- /dev/null +++ b/src/modules/pos/entities/pos-order-line.entity.ts @@ -0,0 +1,173 @@ +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/src/modules/pos/entities/pos-order.entity.ts b/src/modules/pos/entities/pos-order.entity.ts new file mode 100644 index 0000000..c1ad466 --- /dev/null +++ b/src/modules/pos/entities/pos-order.entity.ts @@ -0,0 +1,223 @@ +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/src/modules/pos/entities/pos-payment.entity.ts b/src/modules/pos/entities/pos-payment.entity.ts new file mode 100644 index 0000000..73b6aeb --- /dev/null +++ b/src/modules/pos/entities/pos-payment.entity.ts @@ -0,0 +1,173 @@ +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/src/modules/pos/entities/pos-session.entity.ts b/src/modules/pos/entities/pos-session.entity.ts new file mode 100644 index 0000000..ede6eb2 --- /dev/null +++ b/src/modules/pos/entities/pos-session.entity.ts @@ -0,0 +1,146 @@ +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/src/modules/pos/index.ts b/src/modules/pos/index.ts new file mode 100644 index 0000000..561e3a1 --- /dev/null +++ b/src/modules/pos/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './services'; +export { default as posRoutes } from './routes/pos.routes'; diff --git a/src/modules/pos/routes/pos.routes.ts b/src/modules/pos/routes/pos.routes.ts new file mode 100644 index 0000000..76a0fd3 --- /dev/null +++ b/src/modules/pos/routes/pos.routes.ts @@ -0,0 +1,143 @@ +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/src/modules/pos/services/index.ts b/src/modules/pos/services/index.ts new file mode 100644 index 0000000..8f4d71e --- /dev/null +++ b/src/modules/pos/services/index.ts @@ -0,0 +1,2 @@ +export * from './pos-session.service'; +export * from './pos-order.service'; diff --git a/src/modules/pos/services/pos-order.service.ts b/src/modules/pos/services/pos-order.service.ts new file mode 100644 index 0000000..17fc7f3 --- /dev/null +++ b/src/modules/pos/services/pos-order.service.ts @@ -0,0 +1,914 @@ +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/src/modules/pos/services/pos-session.service.ts b/src/modules/pos/services/pos-session.service.ts new file mode 100644 index 0000000..aa3e4cc --- /dev/null +++ b/src/modules/pos/services/pos-session.service.ts @@ -0,0 +1,360 @@ +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/src/modules/pos/validation/pos.schema.ts b/src/modules/pos/validation/pos.schema.ts new file mode 100644 index 0000000..8f6d67c --- /dev/null +++ b/src/modules/pos/validation/pos.schema.ts @@ -0,0 +1,206 @@ +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/src/modules/pricing/controllers/index.ts b/src/modules/pricing/controllers/index.ts new file mode 100644 index 0000000..0be2cea --- /dev/null +++ b/src/modules/pricing/controllers/index.ts @@ -0,0 +1 @@ +export * from './pricing.controller'; diff --git a/src/modules/pricing/controllers/pricing.controller.ts b/src/modules/pricing/controllers/pricing.controller.ts new file mode 100644 index 0000000..e3cf6a0 --- /dev/null +++ b/src/modules/pricing/controllers/pricing.controller.ts @@ -0,0 +1,985 @@ +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/src/modules/pricing/entities/coupon-redemption.entity.ts b/src/modules/pricing/entities/coupon-redemption.entity.ts new file mode 100644 index 0000000..9879be1 --- /dev/null +++ b/src/modules/pricing/entities/coupon-redemption.entity.ts @@ -0,0 +1,116 @@ +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/src/modules/pricing/entities/coupon.entity.ts b/src/modules/pricing/entities/coupon.entity.ts new file mode 100644 index 0000000..c35e698 --- /dev/null +++ b/src/modules/pricing/entities/coupon.entity.ts @@ -0,0 +1,195 @@ +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/src/modules/pricing/entities/index.ts b/src/modules/pricing/entities/index.ts new file mode 100644 index 0000000..3af0211 --- /dev/null +++ b/src/modules/pricing/entities/index.ts @@ -0,0 +1,4 @@ +export * from './promotion.entity'; +export * from './promotion-product.entity'; +export * from './coupon.entity'; +export * from './coupon-redemption.entity'; diff --git a/src/modules/pricing/entities/promotion-product.entity.ts b/src/modules/pricing/entities/promotion-product.entity.ts new file mode 100644 index 0000000..2ca1ef4 --- /dev/null +++ b/src/modules/pricing/entities/promotion-product.entity.ts @@ -0,0 +1,91 @@ +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/src/modules/pricing/entities/promotion.entity.ts b/src/modules/pricing/entities/promotion.entity.ts new file mode 100644 index 0000000..6a66132 --- /dev/null +++ b/src/modules/pricing/entities/promotion.entity.ts @@ -0,0 +1,224 @@ +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/src/modules/pricing/index.ts b/src/modules/pricing/index.ts new file mode 100644 index 0000000..fd9152d --- /dev/null +++ b/src/modules/pricing/index.ts @@ -0,0 +1,14 @@ +// Services +export * from './services'; + +// Controllers +export * from './controllers'; + +// Routes +export * from './routes'; + +// Validation +export * from './validation'; + +// Entities +export * from './entities'; diff --git a/src/modules/pricing/routes/index.ts b/src/modules/pricing/routes/index.ts new file mode 100644 index 0000000..5aa87c7 --- /dev/null +++ b/src/modules/pricing/routes/index.ts @@ -0,0 +1 @@ +export { default as pricingRoutes } from './pricing.routes'; diff --git a/src/modules/pricing/routes/pricing.routes.ts b/src/modules/pricing/routes/pricing.routes.ts new file mode 100644 index 0000000..3264204 --- /dev/null +++ b/src/modules/pricing/routes/pricing.routes.ts @@ -0,0 +1,306 @@ +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/src/modules/pricing/services/coupon.service.ts b/src/modules/pricing/services/coupon.service.ts new file mode 100644 index 0000000..ef39e3b --- /dev/null +++ b/src/modules/pricing/services/coupon.service.ts @@ -0,0 +1,643 @@ +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/src/modules/pricing/services/index.ts b/src/modules/pricing/services/index.ts new file mode 100644 index 0000000..f3820b2 --- /dev/null +++ b/src/modules/pricing/services/index.ts @@ -0,0 +1,3 @@ +export * from './price-engine.service'; +export * from './promotion.service'; +export * from './coupon.service'; diff --git a/src/modules/pricing/services/price-engine.service.ts b/src/modules/pricing/services/price-engine.service.ts new file mode 100644 index 0000000..b9b3a18 --- /dev/null +++ b/src/modules/pricing/services/price-engine.service.ts @@ -0,0 +1,725 @@ +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/src/modules/pricing/services/promotion.service.ts b/src/modules/pricing/services/promotion.service.ts new file mode 100644 index 0000000..c8f512c --- /dev/null +++ b/src/modules/pricing/services/promotion.service.ts @@ -0,0 +1,607 @@ +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/src/modules/pricing/validation/index.ts b/src/modules/pricing/validation/index.ts new file mode 100644 index 0000000..915fd2c --- /dev/null +++ b/src/modules/pricing/validation/index.ts @@ -0,0 +1 @@ +export * from './pricing.schema'; diff --git a/src/modules/pricing/validation/pricing.schema.ts b/src/modules/pricing/validation/pricing.schema.ts new file mode 100644 index 0000000..1dcbefe --- /dev/null +++ b/src/modules/pricing/validation/pricing.schema.ts @@ -0,0 +1,252 @@ +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/src/modules/purchases/controllers/index.ts b/src/modules/purchases/controllers/index.ts new file mode 100644 index 0000000..6e9c295 --- /dev/null +++ b/src/modules/purchases/controllers/index.ts @@ -0,0 +1 @@ +export * from './purchases.controller'; diff --git a/src/modules/purchases/controllers/purchases.controller.ts b/src/modules/purchases/controllers/purchases.controller.ts new file mode 100644 index 0000000..7e82a44 --- /dev/null +++ b/src/modules/purchases/controllers/purchases.controller.ts @@ -0,0 +1,741 @@ +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/src/modules/purchases/entities/goods-receipt-line.entity.ts b/src/modules/purchases/entities/goods-receipt-line.entity.ts new file mode 100644 index 0000000..99da0b0 --- /dev/null +++ b/src/modules/purchases/entities/goods-receipt-line.entity.ts @@ -0,0 +1,173 @@ +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/src/modules/purchases/entities/goods-receipt.entity.ts b/src/modules/purchases/entities/goods-receipt.entity.ts new file mode 100644 index 0000000..f6d4066 --- /dev/null +++ b/src/modules/purchases/entities/goods-receipt.entity.ts @@ -0,0 +1,174 @@ +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/src/modules/purchases/entities/index.ts b/src/modules/purchases/entities/index.ts new file mode 100644 index 0000000..fdf8b19 --- /dev/null +++ b/src/modules/purchases/entities/index.ts @@ -0,0 +1,5 @@ +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/src/modules/purchases/entities/purchase-suggestion.entity.ts b/src/modules/purchases/entities/purchase-suggestion.entity.ts new file mode 100644 index 0000000..6d37e2d --- /dev/null +++ b/src/modules/purchases/entities/purchase-suggestion.entity.ts @@ -0,0 +1,178 @@ +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/src/modules/purchases/entities/supplier-order-line.entity.ts b/src/modules/purchases/entities/supplier-order-line.entity.ts new file mode 100644 index 0000000..8ebf092 --- /dev/null +++ b/src/modules/purchases/entities/supplier-order-line.entity.ts @@ -0,0 +1,144 @@ +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/src/modules/purchases/entities/supplier-order.entity.ts b/src/modules/purchases/entities/supplier-order.entity.ts new file mode 100644 index 0000000..d30625e --- /dev/null +++ b/src/modules/purchases/entities/supplier-order.entity.ts @@ -0,0 +1,201 @@ +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/src/modules/purchases/index.ts b/src/modules/purchases/index.ts new file mode 100644 index 0000000..0588326 --- /dev/null +++ b/src/modules/purchases/index.ts @@ -0,0 +1,5 @@ +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './routes'; +export * from './validation'; diff --git a/src/modules/purchases/routes/index.ts b/src/modules/purchases/routes/index.ts new file mode 100644 index 0000000..178850f --- /dev/null +++ b/src/modules/purchases/routes/index.ts @@ -0,0 +1 @@ +export { default as purchasesRoutes } from './purchases.routes'; diff --git a/src/modules/purchases/routes/purchases.routes.ts b/src/modules/purchases/routes/purchases.routes.ts new file mode 100644 index 0000000..e905fcd --- /dev/null +++ b/src/modules/purchases/routes/purchases.routes.ts @@ -0,0 +1,252 @@ +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/src/modules/purchases/services/goods-receipt.service.ts b/src/modules/purchases/services/goods-receipt.service.ts new file mode 100644 index 0000000..0458028 --- /dev/null +++ b/src/modules/purchases/services/goods-receipt.service.ts @@ -0,0 +1,700 @@ +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/src/modules/purchases/services/index.ts b/src/modules/purchases/services/index.ts new file mode 100644 index 0000000..4286cd7 --- /dev/null +++ b/src/modules/purchases/services/index.ts @@ -0,0 +1,3 @@ +export * from './supplier-order.service'; +export * from './goods-receipt.service'; +export * from './purchase-suggestion.service'; diff --git a/src/modules/purchases/services/purchase-suggestion.service.ts b/src/modules/purchases/services/purchase-suggestion.service.ts new file mode 100644 index 0000000..9d741bb --- /dev/null +++ b/src/modules/purchases/services/purchase-suggestion.service.ts @@ -0,0 +1,558 @@ +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/src/modules/purchases/services/supplier-order.service.ts b/src/modules/purchases/services/supplier-order.service.ts new file mode 100644 index 0000000..f3727da --- /dev/null +++ b/src/modules/purchases/services/supplier-order.service.ts @@ -0,0 +1,796 @@ +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/src/modules/purchases/validation/index.ts b/src/modules/purchases/validation/index.ts new file mode 100644 index 0000000..00eee6e --- /dev/null +++ b/src/modules/purchases/validation/index.ts @@ -0,0 +1 @@ +export * from './purchases.schema'; diff --git a/src/modules/purchases/validation/purchases.schema.ts b/src/modules/purchases/validation/purchases.schema.ts new file mode 100644 index 0000000..a358bd5 --- /dev/null +++ b/src/modules/purchases/validation/purchases.schema.ts @@ -0,0 +1,385 @@ +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/src/shared/controllers/base.controller.ts b/src/shared/controllers/base.controller.ts new file mode 100644 index 0000000..b49e4fa --- /dev/null +++ b/src/shared/controllers/base.controller.ts @@ -0,0 +1,147 @@ +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/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 0000000..68d10ce --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './services/base.service'; +export * from './middleware'; diff --git a/src/shared/middleware/auth.middleware.ts b/src/shared/middleware/auth.middleware.ts new file mode 100644 index 0000000..c292e09 --- /dev/null +++ b/src/shared/middleware/auth.middleware.ts @@ -0,0 +1,209 @@ +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/src/shared/middleware/branch.middleware.ts b/src/shared/middleware/branch.middleware.ts new file mode 100644 index 0000000..3e2bfbb --- /dev/null +++ b/src/shared/middleware/branch.middleware.ts @@ -0,0 +1,208 @@ +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/src/shared/middleware/index.ts b/src/shared/middleware/index.ts new file mode 100644 index 0000000..4094e17 --- /dev/null +++ b/src/shared/middleware/index.ts @@ -0,0 +1,3 @@ +export * from './tenant.middleware'; +export * from './auth.middleware'; +export * from './branch.middleware'; diff --git a/src/shared/middleware/tenant.middleware.ts b/src/shared/middleware/tenant.middleware.ts new file mode 100644 index 0000000..3e9e1e9 --- /dev/null +++ b/src/shared/middleware/tenant.middleware.ts @@ -0,0 +1,67 @@ +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/src/shared/services/base.service.ts b/src/shared/services/base.service.ts new file mode 100644 index 0000000..7a2763c --- /dev/null +++ b/src/shared/services/base.service.ts @@ -0,0 +1,415 @@ +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/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..a62909d --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1,130 @@ +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/src/shared/validation/common.schema.ts b/src/shared/validation/common.schema.ts new file mode 100644 index 0000000..a2bc675 --- /dev/null +++ b/src/shared/validation/common.schema.ts @@ -0,0 +1,92 @@ +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/src/shared/validation/index.ts b/src/shared/validation/index.ts new file mode 100644 index 0000000..d258d65 --- /dev/null +++ b/src/shared/validation/index.ts @@ -0,0 +1,2 @@ +export * from './common.schema'; +export * from './validation.middleware'; diff --git a/src/shared/validation/validation.middleware.ts b/src/shared/validation/validation.middleware.ts new file mode 100644 index 0000000..c8c02d7 --- /dev/null +++ b/src/shared/validation/validation.middleware.ts @@ -0,0 +1,133 @@ +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/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8b85f0f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "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"] +}