refactor: Configure subrepositorios for backend, database
This commit is contained in:
parent
3b3da382d7
commit
d69f498d5b
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
# =============================================================================
|
||||
# SUBREPOSITORIOS - Tienen sus propios repositorios independientes
|
||||
# =============================================================================
|
||||
backend/
|
||||
database/
|
||||
|
||||
# Dependencias
|
||||
node_modules/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
12
.gitmodules
vendored
Normal file
12
.gitmodules
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
# =============================================================================
|
||||
# Subrepositorios de erp-retail
|
||||
# Cada subproyecto tiene su propio repositorio para deployment independiente
|
||||
# =============================================================================
|
||||
|
||||
[submodule "backend"]
|
||||
path = backend
|
||||
url = git@gitea-server:rckrdmrd/erp-retail-backend.git
|
||||
|
||||
[submodule "database"]
|
||||
path = database
|
||||
url = git@gitea-server:rckrdmrd/erp-retail-database.git
|
||||
@ -1,225 +0,0 @@
|
||||
# Sprint 4: Motor de Precios - Resumen
|
||||
|
||||
## Objetivo
|
||||
Implementar el motor de precios completo para el vertical de Retail, incluyendo gestión de promociones, cupones y cálculo dinámico de precios.
|
||||
|
||||
## Componentes Implementados
|
||||
|
||||
### 1. Servicios
|
||||
|
||||
#### PriceEngineService (`src/modules/pricing/services/price-engine.service.ts`)
|
||||
Motor central de cálculo de precios con las siguientes capacidades:
|
||||
|
||||
- **calculatePrices()**: Calcula precios para un conjunto de líneas de pedido
|
||||
- Aplica promociones automáticas activas
|
||||
- Soporta múltiples tipos de promoción
|
||||
- Gestiona stackability (promociones acumulables)
|
||||
- Aplica cupones si se proporciona código
|
||||
|
||||
- **getActivePromotions()**: Obtiene promociones activas para sucursal/canal
|
||||
|
||||
- **applyCoupon()**: Aplica un cupón al resultado de precios
|
||||
|
||||
- **validateCoupon()**: Valida un código de cupón sin aplicarlo
|
||||
|
||||
**Tipos de Promoción Soportados:**
|
||||
| Tipo | Descripción |
|
||||
|------|-------------|
|
||||
| `percentage_discount` | Descuento porcentual |
|
||||
| `fixed_discount` | Descuento de monto fijo |
|
||||
| `buy_x_get_y` | Compra X, lleva Y |
|
||||
| `bundle` | Precio especial por paquete |
|
||||
| `flash_sale` | Venta relámpago |
|
||||
| `quantity_discount` | Descuento por volumen |
|
||||
| `free_shipping` | Envío gratis |
|
||||
| `gift_with_purchase` | Regalo con compra |
|
||||
|
||||
#### PromotionService (`src/modules/pricing/services/promotion.service.ts`)
|
||||
Servicio CRUD para gestión de promociones:
|
||||
|
||||
- **createPromotion()**: Crear promoción con validación de código único
|
||||
- **updatePromotion()**: Actualizar promoción (solo en estado draft/scheduled)
|
||||
- **listPromotions()**: Listar con filtros paginados
|
||||
- **activatePromotion()**: Activar promoción
|
||||
- **pausePromotion()**: Pausar promoción activa
|
||||
- **endPromotion()**: Finalizar promoción
|
||||
- **cancelPromotion()**: Cancelar promoción
|
||||
- **addProducts()**: Agregar productos a promoción
|
||||
- **removeProduct()**: Remover producto de promoción
|
||||
- **updatePromotionStatuses()**: Actualizar estados automáticamente según fechas
|
||||
|
||||
**Flujo de Estados:**
|
||||
```
|
||||
draft → scheduled → active → ended
|
||||
↓
|
||||
paused
|
||||
↓
|
||||
cancelled
|
||||
```
|
||||
|
||||
#### CouponService (`src/modules/pricing/services/coupon.service.ts`)
|
||||
Servicio completo para gestión de cupones:
|
||||
|
||||
- **createCoupon()**: Crear cupón individual
|
||||
- **generateBulkCoupons()**: Generar cupones en lote (hasta 1000)
|
||||
- **updateCoupon()**: Actualizar cupón
|
||||
- **activateCoupon()**: Activar cupón
|
||||
- **deactivateCoupon()**: Desactivar cupón
|
||||
- **redeemCoupon()**: Canjear cupón con validación completa
|
||||
- **reverseRedemption()**: Revertir un canje (devolución)
|
||||
- **getRedemptionHistory()**: Historial de canjes paginado
|
||||
- **getCouponStats()**: Estadísticas del cupón
|
||||
|
||||
**Tipos de Cupón:**
|
||||
- `percentage`: Descuento porcentual
|
||||
- `fixed_amount`: Monto fijo
|
||||
- `free_shipping`: Envío gratis
|
||||
- `free_product`: Producto gratis
|
||||
|
||||
### 2. Validación Zod (`src/modules/pricing/validation/pricing.schema.ts`)
|
||||
|
||||
Schemas completos para:
|
||||
|
||||
**Promociones:**
|
||||
- `createPromotionSchema`: Creación con todas las opciones
|
||||
- `updatePromotionSchema`: Actualización parcial
|
||||
- `addPromotionProductsSchema`: Agregar productos
|
||||
- `listPromotionsQuerySchema`: Filtros de listado
|
||||
|
||||
**Cupones:**
|
||||
- `createCouponSchema`: Creación con opciones completas
|
||||
- `updateCouponSchema`: Actualización parcial
|
||||
- `generateBulkCouponsSchema`: Generación en lote
|
||||
- `redeemCouponSchema`: Canje de cupón
|
||||
- `validateCouponSchema`: Validación de código
|
||||
- `reverseRedemptionSchema`: Reversión de canje
|
||||
- `listCouponsQuerySchema`: Filtros de listado
|
||||
|
||||
**Motor de Precios:**
|
||||
- `calculatePricesSchema`: Entrada para cálculo
|
||||
- `lineItemSchema`: Línea de producto
|
||||
|
||||
### 3. Controladores (`src/modules/pricing/controllers/pricing.controller.ts`)
|
||||
|
||||
**Endpoints del Motor de Precios (3):**
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/pricing/calculate` | Calcular precios |
|
||||
| POST | `/api/pricing/validate-coupon` | Validar cupón |
|
||||
| GET | `/api/pricing/active-promotions` | Promociones activas |
|
||||
|
||||
**Endpoints de Promociones (11):**
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/pricing/promotions` | Listar promociones |
|
||||
| GET | `/api/pricing/promotions/:id` | Obtener promoción |
|
||||
| POST | `/api/pricing/promotions` | Crear promoción |
|
||||
| PUT | `/api/pricing/promotions/:id` | Actualizar promoción |
|
||||
| POST | `/api/pricing/promotions/:id/activate` | Activar |
|
||||
| POST | `/api/pricing/promotions/:id/pause` | Pausar |
|
||||
| POST | `/api/pricing/promotions/:id/end` | Finalizar |
|
||||
| POST | `/api/pricing/promotions/:id/cancel` | Cancelar |
|
||||
| POST | `/api/pricing/promotions/:id/products` | Agregar productos |
|
||||
| DELETE | `/api/pricing/promotions/:id/products/:productId` | Remover producto |
|
||||
| DELETE | `/api/pricing/promotions/:id` | Eliminar (soft) |
|
||||
|
||||
**Endpoints de Cupones (14):**
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/pricing/coupons` | Listar cupones |
|
||||
| GET | `/api/pricing/coupons/:id` | Obtener cupón |
|
||||
| GET | `/api/pricing/coupons/code/:code` | Obtener por código |
|
||||
| POST | `/api/pricing/coupons` | Crear cupón |
|
||||
| POST | `/api/pricing/coupons/bulk` | Generar lote |
|
||||
| PUT | `/api/pricing/coupons/:id` | Actualizar cupón |
|
||||
| POST | `/api/pricing/coupons/:id/activate` | Activar |
|
||||
| POST | `/api/pricing/coupons/:id/deactivate` | Desactivar |
|
||||
| POST | `/api/pricing/coupons/:id/redeem` | Canjear por ID |
|
||||
| POST | `/api/pricing/coupons/redeem` | Canjear por código |
|
||||
| POST | `/api/pricing/coupons/redemptions/:id/reverse` | Revertir canje |
|
||||
| GET | `/api/pricing/coupons/:id/redemptions` | Historial canjes |
|
||||
| GET | `/api/pricing/coupons/:id/stats` | Estadísticas |
|
||||
| DELETE | `/api/pricing/coupons/:id` | Eliminar (soft) |
|
||||
|
||||
**Total: 28 endpoints**
|
||||
|
||||
### 4. Rutas (`src/modules/pricing/routes/pricing.routes.ts`)
|
||||
|
||||
Configuración de rutas con:
|
||||
- Autenticación JWT requerida
|
||||
- Middleware de sucursal donde aplica
|
||||
- Validación Zod de entrada
|
||||
- Control de permisos granular
|
||||
|
||||
**Permisos Requeridos:**
|
||||
- `pricing:calculate`, `pricing:validate`, `pricing:view`
|
||||
- `promotions:view`, `promotions:create`, `promotions:update`, `promotions:activate`, `promotions:delete`
|
||||
- `coupons:view`, `coupons:create`, `coupons:update`, `coupons:activate`, `coupons:redeem`, `coupons:reverse`, `coupons:delete`
|
||||
|
||||
## Características Destacadas
|
||||
|
||||
### Restricciones de Promociones
|
||||
- Por fechas (inicio/fin)
|
||||
- Por días de la semana
|
||||
- Por horarios específicos
|
||||
- Por monto mínimo de orden
|
||||
- Por cantidad mínima de productos
|
||||
- Por categorías incluidas/excluidas
|
||||
- Por productos excluidos
|
||||
- Por sucursales específicas
|
||||
- Por niveles de cliente
|
||||
- Solo nuevos clientes
|
||||
- Solo miembros de lealtad
|
||||
|
||||
### Stackability (Acumulabilidad)
|
||||
- Promociones pueden ser acumulables o exclusivas
|
||||
- Lista de promociones con las que puede combinarse
|
||||
- Prioridad numérica para resolver conflictos
|
||||
|
||||
### Tracking de Cupones
|
||||
- Límite de usos totales
|
||||
- Límite de usos por cliente
|
||||
- Tracking de canjes con orden y canal
|
||||
- Posibilidad de reversión con razón
|
||||
|
||||
## Estructura de Archivos
|
||||
|
||||
```
|
||||
src/modules/pricing/
|
||||
├── controllers/
|
||||
│ ├── index.ts
|
||||
│ └── pricing.controller.ts
|
||||
├── entities/
|
||||
│ ├── index.ts
|
||||
│ ├── promotion.entity.ts
|
||||
│ ├── promotion-product.entity.ts
|
||||
│ ├── coupon.entity.ts
|
||||
│ └── coupon-redemption.entity.ts
|
||||
├── routes/
|
||||
│ ├── index.ts
|
||||
│ └── pricing.routes.ts
|
||||
├── services/
|
||||
│ ├── index.ts
|
||||
│ ├── price-engine.service.ts
|
||||
│ ├── promotion.service.ts
|
||||
│ └── coupon.service.ts
|
||||
├── validation/
|
||||
│ ├── index.ts
|
||||
│ └── pricing.schema.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
## Dependencias
|
||||
|
||||
- TypeORM 0.3.x
|
||||
- Zod para validación
|
||||
- Express para routing
|
||||
- Entidades existentes: Promotion, Coupon, PromotionProduct, CouponRedemption
|
||||
|
||||
## Próximos Pasos
|
||||
|
||||
El Sprint 5 cubrirá CFDI/Facturación según el roadmap:
|
||||
- Integración con PAC para timbrado
|
||||
- Generación de XML CFDI 4.0
|
||||
- Cancelación de facturas
|
||||
- Notas de crédito
|
||||
@ -1,232 +0,0 @@
|
||||
# Sprint 5: CFDI/Facturación - Resumen
|
||||
|
||||
## Objetivo
|
||||
Implementar el sistema completo de facturación electrónica CFDI 4.0 para el vertical de Retail, con integración a PACs y soporte para autofactura.
|
||||
|
||||
## Componentes Implementados
|
||||
|
||||
### 1. Servicios
|
||||
|
||||
#### XMLService (`src/modules/invoicing/services/xml.service.ts`)
|
||||
Servicio para construcción y manipulación de XML CFDI 4.0:
|
||||
|
||||
- **buildCFDIXML()**: Construye XML completo según especificación SAT
|
||||
- **addSealToXML()**: Agrega sello del contribuyente
|
||||
- **addCertificateToXML()**: Agrega certificado CSD
|
||||
- **addTimbreToXML()**: Agrega complemento TimbreFiscalDigital
|
||||
- **buildCadenaOriginal()**: Genera cadena original para firma
|
||||
- **parseStampedXML()**: Parsea respuesta de PAC
|
||||
- **generateQRData()**: Genera URL de verificación SAT para QR
|
||||
- **validateXMLStructure()**: Validación básica de estructura
|
||||
|
||||
**Características:**
|
||||
- Namespaces CFDI 4.0 y TFD 1.1
|
||||
- Soporte para CFDIs relacionados
|
||||
- Formateo de montos y tasas según SAT
|
||||
- Generación de cadena original del SAT
|
||||
|
||||
#### PACService (`src/modules/invoicing/services/pac.service.ts`)
|
||||
Servicio de integración con Proveedores Autorizados de Certificación:
|
||||
|
||||
- **stampCFDI()**: Timbrado de CFDI
|
||||
- **cancelCFDI()**: Cancelación con motivo
|
||||
- **verifyCFDIStatus()**: Consulta estado en SAT
|
||||
- **encrypt()/decrypt()**: Cifrado de credenciales
|
||||
- **loadCertificate()**: Carga de CSD
|
||||
- **signData()**: Firma con llave privada
|
||||
|
||||
**PACs Soportados:**
|
||||
| PAC | Timbrado | Cancelación | Verificación |
|
||||
|-----|----------|-------------|--------------|
|
||||
| Finkok | ✓ | ✓ | ✓ |
|
||||
| Facturapi | ✓ | ✓ | - |
|
||||
| SW Sapien | ✓ | - | - |
|
||||
| Soluciones Fiscales | Config | Config | Config |
|
||||
| Digisat | Config | Config | Config |
|
||||
|
||||
**Motivos de Cancelación (SAT):**
|
||||
- `01`: Comprobante emitido con errores con relación
|
||||
- `02`: Comprobante emitido con errores sin relación
|
||||
- `03`: No se llevó a cabo la operación
|
||||
- `04`: Operación nominativa relacionada en factura global
|
||||
|
||||
#### CFDIBuilderService (`src/modules/invoicing/services/cfdi-builder.service.ts`)
|
||||
Constructor de estructuras CFDI desde datos de orden:
|
||||
|
||||
- **buildFromOrder()**: Construye CFDI desde líneas de pedido
|
||||
- **buildCreditNote()**: Genera nota de crédito
|
||||
- **buildPaymentComplement()**: Genera complemento de pago
|
||||
- **validateReceptor()**: Valida datos del receptor (CFDI 4.0)
|
||||
- **getUsoCFDIOptions()**: Opciones de uso CFDI por tipo de persona
|
||||
- **getCommonClavesProdServ()**: Claves SAT comunes para retail
|
||||
|
||||
**Cálculos Automáticos:**
|
||||
- Desglose de impuestos (IVA, ISR, IEPS)
|
||||
- Agregación por tipo de impuesto
|
||||
- Manejo de precios con/sin impuesto
|
||||
- Retenciones IVA e ISR
|
||||
|
||||
#### CFDIService (`src/modules/invoicing/services/cfdi.service.ts`)
|
||||
Servicio principal de facturación:
|
||||
|
||||
- **createCFDI()**: Crear factura (draft o timbrada)
|
||||
- **stampCFDI()**: Timbrar factura en borrador
|
||||
- **cancelCFDI()**: Cancelar factura timbrada
|
||||
- **verifyCFDIStatus()**: Verificar estado en SAT
|
||||
- **listCFDIs()**: Listado con filtros paginados
|
||||
- **getCFDIById()**: Obtener por ID
|
||||
- **getCFDIByUUID()**: Obtener por UUID
|
||||
- **downloadXML()**: Descargar XML
|
||||
- **resendEmail()**: Reenviar por correo
|
||||
- **createCreditNote()**: Crear nota de crédito
|
||||
- **getStats()**: Estadísticas de facturación
|
||||
- **getActiveConfig()**: Obtener configuración activa
|
||||
|
||||
### 2. Validación Zod (`src/modules/invoicing/validation/cfdi.schema.ts`)
|
||||
|
||||
**Schemas de CFDI:**
|
||||
- `receptorSchema`: Datos del receptor con validación RFC
|
||||
- `orderLineSchema`: Líneas con claves SAT
|
||||
- `createCFDISchema`: Creación completa
|
||||
- `cancelCFDISchema`: Cancelación con motivo
|
||||
- `listCFDIsQuerySchema`: Filtros de búsqueda
|
||||
- `createCreditNoteSchema`: Nota de crédito
|
||||
|
||||
**Schemas de Configuración:**
|
||||
- `createCFDIConfigSchema`: Configuración PAC y CSD
|
||||
- `updateCFDIConfigSchema`: Actualización parcial
|
||||
- `validateCFDIConfigSchema`: Validación de config
|
||||
|
||||
**Schemas de Autofactura:**
|
||||
- `autofacturaRequestSchema`: Solicitud de autofactura
|
||||
- `lookupOrderSchema`: Búsqueda de ticket
|
||||
|
||||
### 3. Controladores (`src/modules/invoicing/controllers/cfdi.controller.ts`)
|
||||
|
||||
**Endpoints de CFDI (14):**
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| GET | `/cfdis` | Listar CFDIs |
|
||||
| GET | `/cfdis/:id` | Obtener por ID |
|
||||
| GET | `/cfdis/uuid/:uuid` | Obtener por UUID |
|
||||
| POST | `/cfdis` | Crear CFDI |
|
||||
| POST | `/cfdis/:id/stamp` | Timbrar |
|
||||
| POST | `/cfdis/:id/cancel` | Cancelar |
|
||||
| GET | `/cfdis/:id/verify` | Verificar en SAT |
|
||||
| GET | `/cfdis/:id/xml` | Descargar XML |
|
||||
| POST | `/cfdis/:id/resend-email` | Reenviar email |
|
||||
| POST | `/cfdis/:id/credit-note` | Crear nota crédito |
|
||||
| GET | `/stats` | Estadísticas |
|
||||
| GET | `/cancellation-reasons` | Motivos cancelación |
|
||||
| GET | `/uso-cfdi-options` | Opciones UsoCFDI |
|
||||
| GET | `/claves-prod-serv` | Claves SAT |
|
||||
|
||||
**Endpoints de Configuración (5):**
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| GET | `/config` | Obtener config |
|
||||
| POST | `/config` | Crear/actualizar config |
|
||||
| PUT | `/config` | Actualizar config |
|
||||
| POST | `/config/certificate` | Subir CSD |
|
||||
| POST | `/config/validate` | Validar config |
|
||||
|
||||
**Endpoints de Autofactura (2):**
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| POST | `/autofactura/lookup` | Buscar ticket |
|
||||
| POST | `/autofactura/create` | Generar factura |
|
||||
|
||||
**Total: 21 endpoints**
|
||||
|
||||
### 4. Rutas (`src/modules/invoicing/routes/cfdi.routes.ts`)
|
||||
|
||||
**Rutas Públicas:**
|
||||
- Autofactura (lookup, create)
|
||||
- Catálogos SAT (uso CFDI, claves prod/serv, motivos cancelación)
|
||||
|
||||
**Rutas Autenticadas:**
|
||||
- CFDI CRUD y operaciones
|
||||
- Configuración (requiere `cfdi:config:*`)
|
||||
|
||||
**Permisos Requeridos:**
|
||||
- `cfdi:view`, `cfdi:create`, `cfdi:stamp`, `cfdi:cancel`, `cfdi:send`
|
||||
- `cfdi:config:view`, `cfdi:config:manage`
|
||||
|
||||
## Estructura de Archivos
|
||||
|
||||
```
|
||||
src/modules/invoicing/
|
||||
├── controllers/
|
||||
│ ├── index.ts
|
||||
│ └── cfdi.controller.ts
|
||||
├── entities/
|
||||
│ ├── index.ts
|
||||
│ ├── cfdi.entity.ts
|
||||
│ └── cfdi-config.entity.ts
|
||||
├── routes/
|
||||
│ ├── index.ts
|
||||
│ └── cfdi.routes.ts
|
||||
├── services/
|
||||
│ ├── index.ts
|
||||
│ ├── cfdi.service.ts
|
||||
│ ├── cfdi-builder.service.ts
|
||||
│ ├── xml.service.ts
|
||||
│ └── pac.service.ts
|
||||
├── validation/
|
||||
│ ├── index.ts
|
||||
│ └── cfdi.schema.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
## Flujo de Facturación
|
||||
|
||||
```
|
||||
┌────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ Order │────▶│ CFDIBuilder │────▶│ CFDI │
|
||||
│ Data │ │ Service │ │ (draft) │
|
||||
└────────────┘ └──────────────┘ └──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ Stamped │◀────│ PAC │◀────│ XML │
|
||||
│ CFDI │ │ Service │ │ Service │
|
||||
└────────────┘ └──────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
## Estados del CFDI
|
||||
|
||||
```
|
||||
draft ──────▶ pending ──────▶ stamped ──────▶ cancelled
|
||||
│ │
|
||||
▼ ▼
|
||||
error cancellation_pending
|
||||
```
|
||||
|
||||
## Características CFDI 4.0
|
||||
|
||||
- Validación de RFC y régimen fiscal del receptor
|
||||
- Código postal del domicilio fiscal requerido
|
||||
- Objeto de impuesto por concepto
|
||||
- Relacionados con tipo de relación
|
||||
- Exportación (01 = No exportación)
|
||||
|
||||
## Dependencias
|
||||
|
||||
- xmlbuilder2: Construcción de XML
|
||||
- crypto (Node.js): Cifrado y firma
|
||||
- TypeORM 0.3.x
|
||||
- Zod para validación
|
||||
|
||||
## Variables de Entorno Requeridas
|
||||
|
||||
```env
|
||||
PAC_ENCRYPTION_KEY=your-32-byte-encryption-key
|
||||
```
|
||||
|
||||
## Próximos Pasos
|
||||
|
||||
El Sprint 6 según el roadmap cubrirá POS Backend:
|
||||
- Sesiones de POS
|
||||
- Órdenes y líneas
|
||||
- Pagos múltiples
|
||||
- Integración con motor de precios
|
||||
@ -1,216 +0,0 @@
|
||||
# Sprint 6: POS Backend - Resumen
|
||||
|
||||
## Objetivo
|
||||
Completar y mejorar el módulo de Punto de Venta (POS) con funcionalidades avanzadas: reembolsos, órdenes en espera (hold/recall), integración con cupones y estadísticas de sesión.
|
||||
|
||||
## Estado Inicial
|
||||
El módulo POS ya contaba con funcionalidades base:
|
||||
- Apertura/cierre de sesiones
|
||||
- Creación de órdenes y líneas
|
||||
- Procesamiento de pagos múltiples
|
||||
- Anulación de órdenes
|
||||
- Búsqueda de órdenes
|
||||
|
||||
## Componentes Mejorados
|
||||
|
||||
### 1. POSOrderService (Mejoras)
|
||||
|
||||
#### Nuevos Métodos de Reembolso:
|
||||
- **createRefund()**: Crea orden de reembolso desde orden original
|
||||
- Valida orden original pagada
|
||||
- Valida cantidades a reembolsar
|
||||
- Genera líneas negativas
|
||||
- Relaciona con orden original
|
||||
|
||||
- **processRefundPayment()**: Procesa pago de reembolso
|
||||
- Genera movimiento de efectivo negativo
|
||||
- Actualiza totales de sesión
|
||||
- Marca reembolso como completado
|
||||
|
||||
#### Nuevos Métodos Hold/Recall:
|
||||
- **holdOrder()**: Pone orden en espera ("parking")
|
||||
- Solo aplica a órdenes en draft
|
||||
- Guarda nombre/referencia de la orden
|
||||
- Cambia estado a confirmado con metadata
|
||||
|
||||
- **getHeldOrders()**: Lista órdenes en espera por sucursal
|
||||
|
||||
- **recallOrder()**: Recupera orden en espera
|
||||
- Asigna a sesión/caja actual
|
||||
- Vuelve a estado draft
|
||||
|
||||
#### Métodos Adicionales:
|
||||
- **applyCouponToOrder()**: Aplica cupón a orden
|
||||
- **markReceiptPrinted()**: Marca ticket como impreso
|
||||
- **sendReceiptEmail()**: Envía ticket por correo
|
||||
- **getSessionStats()**: Estadísticas de sesión
|
||||
|
||||
### 2. Controladores Nuevos
|
||||
|
||||
**Endpoints de Reembolso (2):**
|
||||
| Método | Ruta | Descripción | Permisos |
|
||||
|--------|------|-------------|----------|
|
||||
| POST | `/pos/refunds` | Crear reembolso | supervisor+ |
|
||||
| POST | `/pos/refunds/:id/process` | Procesar pago | supervisor+ |
|
||||
|
||||
**Endpoints Hold/Recall (3):**
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| POST | `/pos/orders/:id/hold` | Poner en espera |
|
||||
| GET | `/pos/orders/held` | Listar en espera |
|
||||
| POST | `/pos/orders/:id/recall` | Recuperar orden |
|
||||
|
||||
**Endpoints Adicionales (4):**
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| POST | `/pos/orders/:id/coupon` | Aplicar cupón |
|
||||
| POST | `/pos/orders/:id/receipt/print` | Marcar impreso |
|
||||
| POST | `/pos/orders/:id/receipt/send` | Enviar email |
|
||||
| GET | `/pos/sessions/:id/stats` | Estadísticas |
|
||||
|
||||
**Total nuevos endpoints: 9**
|
||||
**Total endpoints POS: 21**
|
||||
|
||||
### 3. Validación Zod (Nuevos Schemas)
|
||||
|
||||
```typescript
|
||||
// Refunds
|
||||
refundLineSchema
|
||||
createRefundSchema
|
||||
processRefundPaymentSchema
|
||||
|
||||
// Hold/Recall
|
||||
holdOrderSchema
|
||||
recallOrderSchema
|
||||
|
||||
// Coupon
|
||||
applyCouponSchema
|
||||
```
|
||||
|
||||
## Estructura de Archivos
|
||||
|
||||
```
|
||||
src/modules/pos/
|
||||
├── controllers/
|
||||
│ └── pos.controller.ts (605 líneas)
|
||||
├── entities/
|
||||
│ ├── index.ts
|
||||
│ ├── pos-session.entity.ts
|
||||
│ ├── pos-order.entity.ts
|
||||
│ ├── pos-order-line.entity.ts
|
||||
│ └── pos-payment.entity.ts
|
||||
├── routes/
|
||||
│ └── pos.routes.ts (143 líneas)
|
||||
├── services/
|
||||
│ ├── index.ts
|
||||
│ ├── pos-session.service.ts (361 líneas)
|
||||
│ └── pos-order.service.ts (915 líneas)
|
||||
├── validation/
|
||||
│ └── pos.schema.ts (207 líneas)
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
## Flujos de Negocio
|
||||
|
||||
### Flujo de Reembolso
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Original │────▶│ Create │────▶│ Refund │
|
||||
│ Order │ │ Refund │ │ (draft) │
|
||||
│ (paid) │ │ │ │ │
|
||||
└─────────────┘ └─────────────┘ └──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Refund │◀────│ Process │
|
||||
│ (paid) │ │ Payment │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### Flujo Hold/Recall
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Order │────▶│ Hold │────▶│ Order │
|
||||
│ (draft) │ │ Order │ │ (held) │
|
||||
└─────────────┘ └─────────────┘ └──────┬──────┘
|
||||
│
|
||||
┌────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Recall │────▶│ Order │
|
||||
│ Order │ │ (draft) │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
## Tipos de Orden
|
||||
|
||||
| Tipo | Código | Descripción |
|
||||
|------|--------|-------------|
|
||||
| SALE | sale | Venta normal |
|
||||
| REFUND | refund | Devolución |
|
||||
| EXCHANGE | exchange | Cambio |
|
||||
|
||||
## Estados de Orden
|
||||
|
||||
| Estado | Descripción |
|
||||
|--------|-------------|
|
||||
| DRAFT | Orden en proceso |
|
||||
| CONFIRMED | Confirmada / En espera (hold) |
|
||||
| PAID | Pagada completamente |
|
||||
| PARTIALLY_PAID | Pago parcial |
|
||||
| VOIDED | Anulada |
|
||||
| REFUNDED | Reembolsada |
|
||||
|
||||
## Permisos Requeridos
|
||||
|
||||
| Acción | Roles Permitidos |
|
||||
|--------|------------------|
|
||||
| Crear orden | Todos autenticados |
|
||||
| Agregar pago | Todos autenticados |
|
||||
| Anular orden | admin, manager, supervisor |
|
||||
| Crear reembolso | admin, manager, supervisor |
|
||||
| Procesar reembolso | admin, manager, supervisor |
|
||||
| Hold/Recall | Todos autenticados |
|
||||
|
||||
## Estadísticas de Sesión
|
||||
|
||||
El endpoint `/pos/sessions/:id/stats` devuelve:
|
||||
```typescript
|
||||
{
|
||||
totalOrders: number; // Órdenes completadas
|
||||
totalSales: number; // Ventas totales
|
||||
totalRefunds: number; // Reembolsos totales
|
||||
totalDiscounts: number; // Descuentos aplicados
|
||||
averageTicket: number; // Ticket promedio
|
||||
paymentBreakdown: { // Desglose por método
|
||||
cash: number;
|
||||
credit_card: number;
|
||||
debit_card: number;
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Integración Pendiente
|
||||
|
||||
- [ ] Integración completa con PriceEngineService para cupones
|
||||
- [ ] Envío real de email para receipts
|
||||
- [ ] Generación de PDF de ticket
|
||||
- [ ] Integración con impresora térmica
|
||||
|
||||
## Dependencias
|
||||
|
||||
- TypeORM 0.3.x
|
||||
- Zod para validación
|
||||
- BaseService para operaciones CRUD
|
||||
- Middleware de autenticación y roles
|
||||
|
||||
## Próximos Pasos
|
||||
|
||||
Sprint 7 según roadmap: POS Frontend
|
||||
- Layout de interfaz POS
|
||||
- Pantalla de ventas
|
||||
- Pantalla de cobro
|
||||
- Componentes de producto
|
||||
- Componentes de pago
|
||||
@ -1,61 +0,0 @@
|
||||
{
|
||||
"name": "@erp-suite/retail-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "Backend for ERP Retail vertical - Point of Sale, Inventory, E-commerce",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"typeorm": "typeorm-ts-node-commonjs",
|
||||
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.ts",
|
||||
"migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.ts",
|
||||
"migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0",
|
||||
"compression": "^1.7.4",
|
||||
"morgan": "^1.10.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"pg": "^8.11.3",
|
||||
"typeorm": "^0.3.28",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4",
|
||||
"winston": "^3.11.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"socket.io": "^4.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.6",
|
||||
"typescript": "^5.3.3",
|
||||
"tsx": "^4.6.2",
|
||||
"eslint": "^8.56.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
||||
"@typescript-eslint/parser": "^6.18.1",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"@types/jest": "^29.5.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@ -1,120 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import express, { Application, Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import morgan from 'morgan';
|
||||
|
||||
// Route imports
|
||||
import branchRoutes from './modules/branches/routes/branch.routes';
|
||||
import posRoutes from './modules/pos/routes/pos.routes';
|
||||
|
||||
// Error type
|
||||
interface AppError extends Error {
|
||||
statusCode?: number;
|
||||
status?: string;
|
||||
isOperational?: boolean;
|
||||
}
|
||||
|
||||
// Create Express app
|
||||
const app: Application = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
|
||||
// CORS configuration
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000', 'http://localhost:5173'],
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Tenant-ID', 'X-Branch-ID'],
|
||||
}));
|
||||
|
||||
// Compression
|
||||
app.use(compression());
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Request logging
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
});
|
||||
});
|
||||
|
||||
// API version info
|
||||
app.get('/api', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
name: 'ERP Retail API',
|
||||
version: '0.1.0',
|
||||
modules: [
|
||||
'branches',
|
||||
'pos',
|
||||
'cash',
|
||||
'inventory',
|
||||
'customers',
|
||||
'pricing',
|
||||
'invoicing',
|
||||
'ecommerce',
|
||||
'purchases',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/branches', branchRoutes);
|
||||
app.use('/api/pos', posRoutes);
|
||||
// TODO: Add remaining route modules as they are implemented
|
||||
// app.use('/api/cash', cashRouter);
|
||||
// app.use('/api/inventory', inventoryRouter);
|
||||
// app.use('/api/customers', customersRouter);
|
||||
// app.use('/api/pricing', pricingRouter);
|
||||
// app.use('/api/invoicing', invoicingRouter);
|
||||
// app.use('/api/ecommerce', ecommerceRouter);
|
||||
// app.use('/api/purchases', purchasesRouter);
|
||||
|
||||
// 404 handler
|
||||
app.use((_req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: 'The requested resource was not found',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use((err: AppError, _req: Request, res: Response, _next: NextFunction) => {
|
||||
const statusCode = err.statusCode || 500;
|
||||
const status = err.status || 'error';
|
||||
|
||||
// Log error
|
||||
console.error('Error:', {
|
||||
message: err.message,
|
||||
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
||||
statusCode,
|
||||
});
|
||||
|
||||
// Send response
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: status.toUpperCase().replace(/ /g, '_'),
|
||||
message: err.isOperational ? err.message : 'An unexpected error occurred',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
@ -1,102 +0,0 @@
|
||||
import { Pool, PoolClient, QueryResult } from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'erp_retail',
|
||||
user: process.env.DB_USER || 'retail_admin',
|
||||
password: process.env.DB_PASSWORD,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
export async function query<T = any>(
|
||||
text: string,
|
||||
params?: any[]
|
||||
): Promise<QueryResult<T>> {
|
||||
const start = Date.now();
|
||||
const res = await pool.query<T>(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<T = any>(
|
||||
text: string,
|
||||
params?: any[]
|
||||
): Promise<T | null> {
|
||||
const res = await query<T>(text, params);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getClient(): Promise<PoolClient> {
|
||||
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<T>(
|
||||
callback: (client: PoolClient) => Promise<T>
|
||||
): Promise<T> {
|
||||
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<void> {
|
||||
await client.query(
|
||||
`SELECT set_config('app.current_tenant_id', $1, true)`,
|
||||
[tenantId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function initDatabase(): Promise<void> {
|
||||
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<void> {
|
||||
await pool.end();
|
||||
console.log('PostgreSQL pool closed');
|
||||
}
|
||||
|
||||
export { pool };
|
||||
@ -1,144 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Entities - Branches
|
||||
import { Branch } from '../modules/branches/entities/branch.entity';
|
||||
import { CashRegister } from '../modules/branches/entities/cash-register.entity';
|
||||
import { BranchUser } from '../modules/branches/entities/branch-user.entity';
|
||||
|
||||
// Entities - POS
|
||||
import { POSSession } from '../modules/pos/entities/pos-session.entity';
|
||||
import { POSOrder } from '../modules/pos/entities/pos-order.entity';
|
||||
import { POSOrderLine } from '../modules/pos/entities/pos-order-line.entity';
|
||||
import { POSPayment } from '../modules/pos/entities/pos-payment.entity';
|
||||
|
||||
// Entities - Cash
|
||||
import { CashMovement } from '../modules/cash/entities/cash-movement.entity';
|
||||
import { CashClosing } from '../modules/cash/entities/cash-closing.entity';
|
||||
import { CashCount } from '../modules/cash/entities/cash-count.entity';
|
||||
|
||||
// Entities - Inventory
|
||||
import { StockTransfer } from '../modules/inventory/entities/stock-transfer.entity';
|
||||
import { StockTransferLine } from '../modules/inventory/entities/stock-transfer-line.entity';
|
||||
import { StockAdjustment } from '../modules/inventory/entities/stock-adjustment.entity';
|
||||
import { StockAdjustmentLine } from '../modules/inventory/entities/stock-adjustment-line.entity';
|
||||
|
||||
// Entities - Customers
|
||||
import { LoyaltyProgram } from '../modules/customers/entities/loyalty-program.entity';
|
||||
import { MembershipLevel } from '../modules/customers/entities/membership-level.entity';
|
||||
import { LoyaltyTransaction } from '../modules/customers/entities/loyalty-transaction.entity';
|
||||
import { CustomerMembership } from '../modules/customers/entities/customer-membership.entity';
|
||||
|
||||
// Entities - Pricing
|
||||
import { Promotion } from '../modules/pricing/entities/promotion.entity';
|
||||
import { PromotionProduct } from '../modules/pricing/entities/promotion-product.entity';
|
||||
import { Coupon } from '../modules/pricing/entities/coupon.entity';
|
||||
import { CouponRedemption } from '../modules/pricing/entities/coupon-redemption.entity';
|
||||
|
||||
// Entities - Invoicing
|
||||
import { CFDIConfig } from '../modules/invoicing/entities/cfdi-config.entity';
|
||||
import { CFDI } from '../modules/invoicing/entities/cfdi.entity';
|
||||
|
||||
// Entities - E-commerce
|
||||
import { Cart } from '../modules/ecommerce/entities/cart.entity';
|
||||
import { CartItem } from '../modules/ecommerce/entities/cart-item.entity';
|
||||
import { EcommerceOrder } from '../modules/ecommerce/entities/ecommerce-order.entity';
|
||||
import { EcommerceOrderLine } from '../modules/ecommerce/entities/ecommerce-order-line.entity';
|
||||
import { ShippingRate } from '../modules/ecommerce/entities/shipping-rate.entity';
|
||||
|
||||
// Entities - Purchases
|
||||
import { PurchaseSuggestion } from '../modules/purchases/entities/purchase-suggestion.entity';
|
||||
import { SupplierOrder } from '../modules/purchases/entities/supplier-order.entity';
|
||||
import { SupplierOrderLine } from '../modules/purchases/entities/supplier-order-line.entity';
|
||||
import { GoodsReceipt } from '../modules/purchases/entities/goods-receipt.entity';
|
||||
import { GoodsReceiptLine } from '../modules/purchases/entities/goods-receipt-line.entity';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const baseConfig: DataSourceOptions = {
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
username: process.env.DB_USER || 'retail_admin',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'erp_retail',
|
||||
schema: 'retail',
|
||||
entities: [
|
||||
// Branches
|
||||
Branch,
|
||||
CashRegister,
|
||||
BranchUser,
|
||||
// POS
|
||||
POSSession,
|
||||
POSOrder,
|
||||
POSOrderLine,
|
||||
POSPayment,
|
||||
// Cash
|
||||
CashMovement,
|
||||
CashClosing,
|
||||
CashCount,
|
||||
// Inventory
|
||||
StockTransfer,
|
||||
StockTransferLine,
|
||||
StockAdjustment,
|
||||
StockAdjustmentLine,
|
||||
// Customers
|
||||
LoyaltyProgram,
|
||||
MembershipLevel,
|
||||
LoyaltyTransaction,
|
||||
CustomerMembership,
|
||||
// Pricing
|
||||
Promotion,
|
||||
PromotionProduct,
|
||||
Coupon,
|
||||
CouponRedemption,
|
||||
// Invoicing
|
||||
CFDIConfig,
|
||||
CFDI,
|
||||
// E-commerce
|
||||
Cart,
|
||||
CartItem,
|
||||
EcommerceOrder,
|
||||
EcommerceOrderLine,
|
||||
ShippingRate,
|
||||
// Purchases
|
||||
PurchaseSuggestion,
|
||||
SupplierOrder,
|
||||
SupplierOrderLine,
|
||||
GoodsReceipt,
|
||||
GoodsReceiptLine,
|
||||
],
|
||||
migrations: ['src/migrations/*.ts'],
|
||||
synchronize: false, // NEVER use in production
|
||||
logging: isProduction ? ['error'] : ['query', 'error'],
|
||||
maxQueryExecutionTime: 1000, // Log slow queries
|
||||
extra: {
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
},
|
||||
};
|
||||
|
||||
export const AppDataSource = new DataSource(baseConfig);
|
||||
|
||||
export async function initTypeORM(): Promise<DataSource> {
|
||||
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<void> {
|
||||
if (AppDataSource.isInitialized) {
|
||||
await AppDataSource.destroy();
|
||||
console.log('TypeORM DataSource closed');
|
||||
}
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables first
|
||||
dotenv.config();
|
||||
|
||||
import app from './app';
|
||||
import { initDatabase, closeDatabase } from './config/database';
|
||||
import { initTypeORM, closeTypeORM } from './config/typeorm';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
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<void> => {
|
||||
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();
|
||||
@ -1,236 +0,0 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { BaseController } from '../../../shared/controllers/base.controller';
|
||||
import { AuthenticatedRequest } from '../../../shared/types';
|
||||
import { branchService } from '../services/branch.service';
|
||||
import { BranchStatus, BranchType } from '../entities/branch.entity';
|
||||
|
||||
class BranchController extends BaseController {
|
||||
/**
|
||||
* GET /branches - List all branches
|
||||
*/
|
||||
async list(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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();
|
||||
@ -1,122 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Branch } from './branch.entity';
|
||||
|
||||
export enum BranchUserRole {
|
||||
CASHIER = 'cashier',
|
||||
SUPERVISOR = 'supervisor',
|
||||
MANAGER = 'manager',
|
||||
INVENTORY = 'inventory',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
@Entity('branch_users', { schema: 'retail' })
|
||||
@Index(['tenantId', 'branchId', 'userId'], { unique: true })
|
||||
@Index(['tenantId', 'userId'])
|
||||
export class BranchUser {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid' })
|
||||
branchId: string;
|
||||
|
||||
// Reference to erp-core user
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: BranchUserRole,
|
||||
default: BranchUserRole.CASHIER,
|
||||
})
|
||||
role: BranchUserRole;
|
||||
|
||||
// PIN for quick login at POS
|
||||
@Column({ name: 'pos_pin', length: 6, nullable: true })
|
||||
posPin: string;
|
||||
|
||||
// Permissions (granular override of role)
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
permissions: {
|
||||
canOpenSession?: boolean;
|
||||
canCloseSession?: boolean;
|
||||
canVoidSale?: boolean;
|
||||
canApplyDiscount?: boolean;
|
||||
maxDiscountPercent?: number;
|
||||
canRefund?: boolean;
|
||||
canViewReports?: boolean;
|
||||
canManageInventory?: boolean;
|
||||
canManagePrices?: boolean;
|
||||
canManageUsers?: boolean;
|
||||
};
|
||||
|
||||
// Commission settings
|
||||
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 4, nullable: true })
|
||||
commissionRate: number;
|
||||
|
||||
@Column({ name: 'commission_type', length: 20, nullable: true })
|
||||
commissionType: 'percentage' | 'fixed_per_sale' | 'fixed_per_item';
|
||||
|
||||
// Work schedule
|
||||
@Column({ name: 'work_schedule', type: 'jsonb', nullable: true })
|
||||
workSchedule: {
|
||||
monday?: { start: string; end: string };
|
||||
tuesday?: { start: string; end: string };
|
||||
wednesday?: { start: string; end: string };
|
||||
thursday?: { start: string; end: string };
|
||||
friday?: { start: string; end: string };
|
||||
saturday?: { start: string; end: string };
|
||||
sunday?: { start: string; end: string };
|
||||
};
|
||||
|
||||
// Default register assignment
|
||||
@Column({ name: 'default_register_id', type: 'uuid', nullable: true })
|
||||
defaultRegisterId: string;
|
||||
|
||||
// Activity tracking
|
||||
@Column({ name: 'last_login_at', type: 'timestamp with time zone', nullable: true })
|
||||
lastLoginAt: Date;
|
||||
|
||||
@Column({ name: 'last_activity_at', type: 'timestamp with time zone', nullable: true })
|
||||
lastActivityAt: Date;
|
||||
|
||||
// Status
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ name: 'is_primary_branch', type: 'boolean', default: false })
|
||||
isPrimaryBranch: boolean;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@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;
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { CashRegister } from './cash-register.entity';
|
||||
import { BranchUser } from './branch-user.entity';
|
||||
|
||||
export enum BranchType {
|
||||
STORE = 'store',
|
||||
WAREHOUSE = 'warehouse',
|
||||
HYBRID = 'hybrid',
|
||||
}
|
||||
|
||||
export enum BranchStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
TEMPORARILY_CLOSED = 'temporarily_closed',
|
||||
}
|
||||
|
||||
@Entity('branches', { schema: 'retail' })
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
@Index(['tenantId', 'status'])
|
||||
export class Branch {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 20, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: BranchType,
|
||||
default: BranchType.STORE,
|
||||
})
|
||||
type: BranchType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: BranchStatus,
|
||||
default: BranchStatus.ACTIVE,
|
||||
})
|
||||
status: BranchStatus;
|
||||
|
||||
// Address
|
||||
@Column({ length: 255, nullable: true })
|
||||
address: string;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
city: string;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
state: string;
|
||||
|
||||
@Column({ name: 'postal_code', length: 10, nullable: true })
|
||||
postalCode: string;
|
||||
|
||||
@Column({ name: 'country_code', length: 3, default: 'MEX' })
|
||||
countryCode: string;
|
||||
|
||||
// Contact
|
||||
@Column({ length: 20, nullable: true })
|
||||
phone: string;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
email: string;
|
||||
|
||||
// Manager reference (from erp-core users)
|
||||
@Column({ name: 'manager_id', type: 'uuid', nullable: true })
|
||||
managerId: string;
|
||||
|
||||
// Inventory reference (from erp-core)
|
||||
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||
warehouseId: string;
|
||||
|
||||
// Operating hours (JSON)
|
||||
@Column({ name: 'operating_hours', type: 'jsonb', nullable: true })
|
||||
operatingHours: {
|
||||
monday?: { open: string; close: string };
|
||||
tuesday?: { open: string; close: string };
|
||||
wednesday?: { open: string; close: string };
|
||||
thursday?: { open: string; close: string };
|
||||
friday?: { open: string; close: string };
|
||||
saturday?: { open: string; close: string };
|
||||
sunday?: { open: string; close: string };
|
||||
};
|
||||
|
||||
// POS Configuration
|
||||
@Column({ name: 'default_price_list_id', type: 'uuid', nullable: true })
|
||||
defaultPriceListId: string;
|
||||
|
||||
@Column({ name: 'allow_negative_stock', type: 'boolean', default: false })
|
||||
allowNegativeStock: boolean;
|
||||
|
||||
@Column({ name: 'require_customer_for_sale', type: 'boolean', default: false })
|
||||
requireCustomerForSale: boolean;
|
||||
|
||||
@Column({ name: 'auto_print_receipt', type: 'boolean', default: true })
|
||||
autoPrintReceipt: boolean;
|
||||
|
||||
// Fiscal
|
||||
@Column({ name: 'fiscal_address', type: 'text', nullable: true })
|
||||
fiscalAddress: string;
|
||||
|
||||
@Column({ name: 'cfdi_config_id', type: 'uuid', nullable: true })
|
||||
cfdiConfigId: string;
|
||||
|
||||
// Geolocation
|
||||
@Column({ type: 'decimal', precision: 10, scale: 7, nullable: true })
|
||||
latitude: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 7, nullable: true })
|
||||
longitude: number;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@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[];
|
||||
}
|
||||
@ -1,147 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Branch } from './branch.entity';
|
||||
|
||||
export enum RegisterStatus {
|
||||
AVAILABLE = 'available',
|
||||
IN_USE = 'in_use',
|
||||
CLOSED = 'closed',
|
||||
MAINTENANCE = 'maintenance',
|
||||
}
|
||||
|
||||
export enum RegisterType {
|
||||
STANDARD = 'standard',
|
||||
EXPRESS = 'express',
|
||||
SELF_SERVICE = 'self_service',
|
||||
}
|
||||
|
||||
@Entity('cash_registers', { schema: 'retail' })
|
||||
@Index(['tenantId', 'branchId', 'code'], { unique: true })
|
||||
@Index(['tenantId', 'status'])
|
||||
export class CashRegister {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid' })
|
||||
branchId: string;
|
||||
|
||||
@Column({ length: 20 })
|
||||
code: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: RegisterType,
|
||||
default: RegisterType.STANDARD,
|
||||
})
|
||||
type: RegisterType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: RegisterStatus,
|
||||
default: RegisterStatus.AVAILABLE,
|
||||
})
|
||||
status: RegisterStatus;
|
||||
|
||||
// Hardware configuration
|
||||
@Column({ name: 'printer_config', type: 'jsonb', nullable: true })
|
||||
printerConfig: {
|
||||
type: 'thermal' | 'inkjet' | 'none';
|
||||
connectionType: 'usb' | 'network' | 'bluetooth';
|
||||
address?: string;
|
||||
port?: number;
|
||||
paperWidth: 58 | 80;
|
||||
};
|
||||
|
||||
@Column({ name: 'scanner_config', type: 'jsonb', nullable: true })
|
||||
scannerConfig: {
|
||||
enabled: boolean;
|
||||
type: 'usb' | 'bluetooth';
|
||||
};
|
||||
|
||||
@Column({ name: 'cash_drawer_config', type: 'jsonb', nullable: true })
|
||||
cashDrawerConfig: {
|
||||
enabled: boolean;
|
||||
openCommand: string;
|
||||
};
|
||||
|
||||
@Column({ name: 'display_config', type: 'jsonb', nullable: true })
|
||||
displayConfig: {
|
||||
enabled: boolean;
|
||||
type: 'lcd' | 'pole' | 'none';
|
||||
};
|
||||
|
||||
// Payment methods allowed
|
||||
@Column({ name: 'allowed_payment_methods', type: 'jsonb', default: '["cash", "card", "transfer"]' })
|
||||
allowedPaymentMethods: string[];
|
||||
|
||||
// Current session info (denormalized for quick access)
|
||||
@Column({ name: 'current_session_id', type: 'uuid', nullable: true })
|
||||
currentSessionId: string;
|
||||
|
||||
@Column({ name: 'current_user_id', type: 'uuid', nullable: true })
|
||||
currentUserId: string;
|
||||
|
||||
// Cash limits
|
||||
@Column({ name: 'initial_cash_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
initialCashAmount: number;
|
||||
|
||||
@Column({ name: 'max_cash_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
maxCashAmount: number;
|
||||
|
||||
// Settings
|
||||
@Column({ name: 'require_closing_count', type: 'boolean', default: true })
|
||||
requireClosingCount: boolean;
|
||||
|
||||
@Column({ name: 'allow_blind_close', type: 'boolean', default: false })
|
||||
allowBlindClose: boolean;
|
||||
|
||||
@Column({ name: 'auto_logout_minutes', type: 'int', default: 30 })
|
||||
autoLogoutMinutes: number;
|
||||
|
||||
// Device identifier for offline sync
|
||||
@Column({ name: 'device_uuid', type: 'uuid', nullable: true })
|
||||
deviceUuid: string;
|
||||
|
||||
@Column({ name: 'last_sync_at', type: 'timestamp with time zone', nullable: true })
|
||||
lastSyncAt: Date;
|
||||
|
||||
// Active flag
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@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;
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from './branch.entity';
|
||||
export * from './cash-register.entity';
|
||||
export * from './branch-user.entity';
|
||||
@ -1,3 +0,0 @@
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
export { default as branchRoutes } from './routes/branch.routes';
|
||||
@ -1,48 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { branchController } from '../controllers/branch.controller';
|
||||
import { authMiddleware, requireRoles } from '../../../shared/middleware/auth.middleware';
|
||||
import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware';
|
||||
import { AuthenticatedRequest } from '../../../shared/types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require tenant and authentication
|
||||
router.use(tenantMiddleware);
|
||||
router.use(authMiddleware);
|
||||
|
||||
// List routes
|
||||
router.get('/', (req, res, next) => branchController.list(req as AuthenticatedRequest, res, next));
|
||||
router.get('/active', (req, res, next) => branchController.listActive(req as AuthenticatedRequest, res, next));
|
||||
router.get('/nearby', (req, res, next) => branchController.findNearby(req as AuthenticatedRequest, res, next));
|
||||
|
||||
// Single branch routes
|
||||
router.get('/code/:code', (req, res, next) => branchController.getByCode(req as AuthenticatedRequest, res, next));
|
||||
router.get('/:id', (req, res, next) => branchController.getById(req as AuthenticatedRequest, res, next));
|
||||
router.get('/:id/stats', (req, res, next) => branchController.getStats(req as AuthenticatedRequest, res, next));
|
||||
|
||||
// Admin routes (require manager or admin role)
|
||||
router.post(
|
||||
'/',
|
||||
requireRoles('admin', 'manager'),
|
||||
(req, res, next) => branchController.create(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
requireRoles('admin', 'manager'),
|
||||
(req, res, next) => branchController.update(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:id/status',
|
||||
requireRoles('admin', 'manager'),
|
||||
(req, res, next) => branchController.updateStatus(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRoles('admin'),
|
||||
(req, res, next) => branchController.delete(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
export default router;
|
||||
@ -1,243 +0,0 @@
|
||||
import { Repository, DeepPartial } from 'typeorm';
|
||||
import { AppDataSource } from '../../../config/typeorm';
|
||||
import { BaseService } from '../../../shared/services/base.service';
|
||||
import { ServiceResult, QueryOptions } from '../../../shared/types';
|
||||
import { Branch, BranchStatus, BranchType } from '../entities/branch.entity';
|
||||
import { CashRegister } from '../entities/cash-register.entity';
|
||||
|
||||
export class BranchService extends BaseService<Branch> {
|
||||
private cashRegisterRepository: Repository<CashRegister>;
|
||||
|
||||
constructor() {
|
||||
super(AppDataSource.getRepository(Branch));
|
||||
this.cashRegisterRepository = AppDataSource.getRepository(CashRegister);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all active branches for a tenant
|
||||
*/
|
||||
async findActiveBranches(tenantId: string): Promise<Branch[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, status: BranchStatus.ACTIVE },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find branch by code
|
||||
*/
|
||||
async findByCode(tenantId: string, code: string): Promise<Branch | null> {
|
||||
return this.repository.findOne({
|
||||
where: { tenantId, code },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find branches by type
|
||||
*/
|
||||
async findByType(tenantId: string, type: BranchType): Promise<Branch[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, type },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create branch with default cash register
|
||||
*/
|
||||
async createWithRegister(
|
||||
tenantId: string,
|
||||
data: DeepPartial<Branch>,
|
||||
userId?: string
|
||||
): Promise<ServiceResult<Branch>> {
|
||||
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<ServiceResult<Branch>> {
|
||||
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<boolean> {
|
||||
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<Branch | null> {
|
||||
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<Branch[]> {
|
||||
// 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();
|
||||
@ -1 +0,0 @@
|
||||
export * from './branch.service';
|
||||
@ -1,82 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
uuidSchema,
|
||||
emailSchema,
|
||||
phoneSchema,
|
||||
postalCodeSchema,
|
||||
operatingHoursSchema,
|
||||
coordinatesSchema,
|
||||
paginationSchema,
|
||||
} from '../../../shared/validation/common.schema';
|
||||
|
||||
// Enums
|
||||
export const branchTypeEnum = z.enum(['store', 'warehouse', 'hybrid']);
|
||||
export const branchStatusEnum = z.enum(['active', 'inactive', 'temporarily_closed']);
|
||||
|
||||
// Create branch schema
|
||||
export const createBranchSchema = z.object({
|
||||
code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'),
|
||||
name: z.string().min(1).max(100),
|
||||
type: branchTypeEnum.default('store'),
|
||||
status: branchStatusEnum.default('active'),
|
||||
|
||||
// Address
|
||||
address: z.string().max(255).optional(),
|
||||
city: z.string().max(100).optional(),
|
||||
state: z.string().max(100).optional(),
|
||||
postalCode: postalCodeSchema.optional(),
|
||||
countryCode: z.string().length(3).default('MEX'),
|
||||
|
||||
// Contact
|
||||
phone: phoneSchema.optional(),
|
||||
email: emailSchema.optional(),
|
||||
|
||||
// References
|
||||
managerId: uuidSchema.optional(),
|
||||
warehouseId: uuidSchema.optional(),
|
||||
|
||||
// Configuration
|
||||
operatingHours: operatingHoursSchema,
|
||||
defaultPriceListId: uuidSchema.optional(),
|
||||
allowNegativeStock: z.boolean().default(false),
|
||||
requireCustomerForSale: z.boolean().default(false),
|
||||
autoPrintReceipt: z.boolean().default(true),
|
||||
|
||||
// Fiscal
|
||||
fiscalAddress: z.string().optional(),
|
||||
cfdiConfigId: uuidSchema.optional(),
|
||||
|
||||
// Geolocation
|
||||
latitude: z.coerce.number().min(-90).max(90).optional(),
|
||||
longitude: z.coerce.number().min(-180).max(180).optional(),
|
||||
|
||||
// Metadata
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
// Update branch schema (all fields optional)
|
||||
export const updateBranchSchema = createBranchSchema.partial().omit({ code: true });
|
||||
|
||||
// Update status schema
|
||||
export const updateBranchStatusSchema = z.object({
|
||||
status: branchStatusEnum,
|
||||
});
|
||||
|
||||
// List branches query schema
|
||||
export const listBranchesQuerySchema = paginationSchema.extend({
|
||||
status: branchStatusEnum.optional(),
|
||||
type: branchTypeEnum.optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
// Find nearby query schema
|
||||
export const findNearbyQuerySchema = z.object({
|
||||
lat: z.coerce.number().min(-90).max(90),
|
||||
lng: z.coerce.number().min(-180).max(180),
|
||||
radius: z.coerce.number().min(0.1).max(100).default(10),
|
||||
});
|
||||
|
||||
// Types
|
||||
export type CreateBranchInput = z.infer<typeof createBranchSchema>;
|
||||
export type UpdateBranchInput = z.infer<typeof updateBranchSchema>;
|
||||
export type ListBranchesQuery = z.infer<typeof listBranchesQuerySchema>;
|
||||
@ -1,330 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { BaseController } from '../../../shared/controllers/base.controller';
|
||||
import { CashMovementService, MovementQueryOptions } from '../services/cash-movement.service';
|
||||
import { CashClosingService, ClosingQueryOptions } from '../services/cash-closing.service';
|
||||
|
||||
export class CashController extends BaseController {
|
||||
constructor(
|
||||
private readonly movementService: CashMovementService,
|
||||
private readonly closingService: CashClosingService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ==================== MOVEMENT ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* Create a cash movement
|
||||
* POST /cash/movements
|
||||
*/
|
||||
createMovement = async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
};
|
||||
}
|
||||
@ -1,205 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { CashCount } from './cash-count.entity';
|
||||
|
||||
export enum ClosingStatus {
|
||||
IN_PROGRESS = 'in_progress',
|
||||
PENDING_REVIEW = 'pending_review',
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected',
|
||||
RECONCILED = 'reconciled',
|
||||
}
|
||||
|
||||
export enum ClosingType {
|
||||
SHIFT = 'shift',
|
||||
DAILY = 'daily',
|
||||
WEEKLY = 'weekly',
|
||||
MONTHLY = 'monthly',
|
||||
}
|
||||
|
||||
@Entity('cash_closings', { schema: 'retail' })
|
||||
@Index(['tenantId', 'branchId', 'closingDate'])
|
||||
@Index(['tenantId', 'status'])
|
||||
@Index(['tenantId', 'sessionId'])
|
||||
export class CashClosing {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid' })
|
||||
branchId: string;
|
||||
|
||||
@Column({ name: 'register_id', type: 'uuid', nullable: true })
|
||||
registerId: string;
|
||||
|
||||
@Column({ name: 'session_id', type: 'uuid', nullable: true })
|
||||
sessionId: string;
|
||||
|
||||
@Column({ length: 30, unique: true })
|
||||
number: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ClosingType,
|
||||
default: ClosingType.SHIFT,
|
||||
})
|
||||
type: ClosingType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ClosingStatus,
|
||||
default: ClosingStatus.IN_PROGRESS,
|
||||
})
|
||||
status: ClosingStatus;
|
||||
|
||||
@Column({ name: 'closing_date', type: 'date' })
|
||||
closingDate: Date;
|
||||
|
||||
@Column({ name: 'period_start', type: 'timestamp with time zone' })
|
||||
periodStart: Date;
|
||||
|
||||
@Column({ name: 'period_end', type: 'timestamp with time zone' })
|
||||
periodEnd: Date;
|
||||
|
||||
// Opening balance
|
||||
@Column({ name: 'opening_balance', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
openingBalance: number;
|
||||
|
||||
// Expected amounts by payment method
|
||||
@Column({ name: 'expected_cash', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
expectedCash: number;
|
||||
|
||||
@Column({ name: 'expected_card', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
expectedCard: number;
|
||||
|
||||
@Column({ name: 'expected_transfer', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
expectedTransfer: number;
|
||||
|
||||
@Column({ name: 'expected_other', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
expectedOther: number;
|
||||
|
||||
@Column({ name: 'expected_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
expectedTotal: number;
|
||||
|
||||
// Counted amounts
|
||||
@Column({ name: 'counted_cash', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
countedCash: number;
|
||||
|
||||
@Column({ name: 'counted_card', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
countedCard: number;
|
||||
|
||||
@Column({ name: 'counted_transfer', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
countedTransfer: number;
|
||||
|
||||
@Column({ name: 'counted_other', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
countedOther: number;
|
||||
|
||||
@Column({ name: 'counted_total', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
countedTotal: number;
|
||||
|
||||
// Differences
|
||||
@Column({ name: 'cash_difference', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
cashDifference: number;
|
||||
|
||||
@Column({ name: 'card_difference', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
cardDifference: number;
|
||||
|
||||
@Column({ name: 'total_difference', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
totalDifference: number;
|
||||
|
||||
// Transaction summary
|
||||
@Column({ name: 'total_sales', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalSales: number;
|
||||
|
||||
@Column({ name: 'total_refunds', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalRefunds: number;
|
||||
|
||||
@Column({ name: 'total_discounts', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalDiscounts: number;
|
||||
|
||||
@Column({ name: 'net_sales', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
netSales: number;
|
||||
|
||||
@Column({ name: 'orders_count', type: 'int', default: 0 })
|
||||
ordersCount: number;
|
||||
|
||||
@Column({ name: 'refunds_count', type: 'int', default: 0 })
|
||||
refundsCount: number;
|
||||
|
||||
@Column({ name: 'voided_count', type: 'int', default: 0 })
|
||||
voidedCount: number;
|
||||
|
||||
// Cash movements
|
||||
@Column({ name: 'cash_in_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
cashInTotal: number;
|
||||
|
||||
@Column({ name: 'cash_out_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
cashOutTotal: number;
|
||||
|
||||
// Deposit info
|
||||
@Column({ name: 'deposit_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
depositAmount: number;
|
||||
|
||||
@Column({ name: 'deposit_reference', length: 100, nullable: true })
|
||||
depositReference: string;
|
||||
|
||||
@Column({ name: 'deposit_date', type: 'date', nullable: true })
|
||||
depositDate: Date;
|
||||
|
||||
// User info
|
||||
@Column({ name: 'closed_by', type: 'uuid' })
|
||||
closedBy: string;
|
||||
|
||||
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
|
||||
reviewedBy: string;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'timestamp with time zone', nullable: true })
|
||||
reviewedAt: Date;
|
||||
|
||||
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
|
||||
approvedBy: string;
|
||||
|
||||
@Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true })
|
||||
approvedAt: Date;
|
||||
|
||||
// Notes
|
||||
@Column({ name: 'closing_notes', type: 'text', nullable: true })
|
||||
closingNotes: string;
|
||||
|
||||
@Column({ name: 'review_notes', type: 'text', nullable: true })
|
||||
reviewNotes: string;
|
||||
|
||||
// Detailed breakdown by payment method
|
||||
@Column({ name: 'payment_breakdown', type: 'jsonb', nullable: true })
|
||||
paymentBreakdown: {
|
||||
method: string;
|
||||
expected: number;
|
||||
counted: number;
|
||||
difference: number;
|
||||
transactionCount: number;
|
||||
}[];
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => CashCount, (count) => count.closing)
|
||||
cashCounts: CashCount[];
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { CashClosing } from './cash-closing.entity';
|
||||
|
||||
export enum DenominationType {
|
||||
BILL = 'bill',
|
||||
COIN = 'coin',
|
||||
}
|
||||
|
||||
@Entity('cash_counts', { schema: 'retail' })
|
||||
@Index(['tenantId', 'closingId'])
|
||||
@Index(['tenantId', 'sessionId'])
|
||||
export class CashCount {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'closing_id', type: 'uuid', nullable: true })
|
||||
closingId: string;
|
||||
|
||||
@Column({ name: 'session_id', type: 'uuid', nullable: true })
|
||||
sessionId: string;
|
||||
|
||||
@Column({ name: 'register_id', type: 'uuid' })
|
||||
registerId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: DenominationType,
|
||||
})
|
||||
type: DenominationType;
|
||||
|
||||
// Denomination value (e.g., 500, 200, 100, 50, 20, 10, 5, 2, 1, 0.50, 0.20, 0.10)
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
denomination: number;
|
||||
|
||||
// Count
|
||||
@Column({ type: 'int', default: 0 })
|
||||
quantity: number;
|
||||
|
||||
// Calculated total (denomination * quantity)
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
total: number;
|
||||
|
||||
// Currency
|
||||
@Column({ name: 'currency_code', length: 3, default: 'MXN' })
|
||||
currencyCode: string;
|
||||
|
||||
// Count context
|
||||
@Column({ name: 'count_type', length: 20, default: 'closing' })
|
||||
countType: 'opening' | 'closing' | 'audit' | 'transfer';
|
||||
|
||||
// User who counted
|
||||
@Column({ name: 'counted_by', type: 'uuid' })
|
||||
countedBy: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => CashClosing, (closing) => closing.cashCounts)
|
||||
@JoinColumn({ name: 'closing_id' })
|
||||
closing: CashClosing;
|
||||
}
|
||||
@ -1,152 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum MovementType {
|
||||
CASH_IN = 'cash_in',
|
||||
CASH_OUT = 'cash_out',
|
||||
DEPOSIT = 'deposit',
|
||||
WITHDRAWAL = 'withdrawal',
|
||||
ADJUSTMENT = 'adjustment',
|
||||
OPENING = 'opening',
|
||||
CLOSING = 'closing',
|
||||
}
|
||||
|
||||
export enum MovementReason {
|
||||
CHANGE_FUND = 'change_fund',
|
||||
PETTY_CASH = 'petty_cash',
|
||||
BANK_DEPOSIT = 'bank_deposit',
|
||||
SUPPLIER_PAYMENT = 'supplier_payment',
|
||||
EXPENSE = 'expense',
|
||||
SALARY_ADVANCE = 'salary_advance',
|
||||
CORRECTION = 'correction',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export enum MovementStatus {
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
@Entity('cash_movements', { schema: 'retail' })
|
||||
@Index(['tenantId', 'branchId', 'createdAt'])
|
||||
@Index(['tenantId', 'sessionId'])
|
||||
@Index(['tenantId', 'type', 'status'])
|
||||
export class CashMovement {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid' })
|
||||
branchId: string;
|
||||
|
||||
@Column({ name: 'register_id', type: 'uuid' })
|
||||
registerId: string;
|
||||
|
||||
@Column({ name: 'session_id', type: 'uuid' })
|
||||
sessionId: string;
|
||||
|
||||
@Column({ length: 30, unique: true })
|
||||
number: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MovementType,
|
||||
})
|
||||
type: MovementType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MovementReason,
|
||||
default: MovementReason.OTHER,
|
||||
})
|
||||
reason: MovementReason;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MovementStatus,
|
||||
default: MovementStatus.PENDING,
|
||||
})
|
||||
status: MovementStatus;
|
||||
|
||||
// Amount (positive for in, negative for out stored as absolute)
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
// Running balance after movement
|
||||
@Column({ name: 'balance_after', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
balanceAfter: number;
|
||||
|
||||
// Description
|
||||
@Column({ type: 'text' })
|
||||
description: string;
|
||||
|
||||
// Reference (external document, invoice, etc.)
|
||||
@Column({ name: 'reference_type', length: 50, nullable: true })
|
||||
referenceType: string;
|
||||
|
||||
@Column({ name: 'reference_id', type: 'uuid', nullable: true })
|
||||
referenceId: string;
|
||||
|
||||
@Column({ name: 'reference_number', length: 50, nullable: true })
|
||||
referenceNumber: string;
|
||||
|
||||
// Authorization
|
||||
@Column({ name: 'requires_approval', type: 'boolean', default: false })
|
||||
requiresApproval: boolean;
|
||||
|
||||
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
|
||||
approvedBy: string;
|
||||
|
||||
@Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true })
|
||||
approvedAt: Date;
|
||||
|
||||
@Column({ name: 'rejection_reason', length: 255, nullable: true })
|
||||
rejectionReason: string;
|
||||
|
||||
// Person who receives/gives cash (for expenses, advances, etc.)
|
||||
@Column({ name: 'recipient_name', length: 200, nullable: true })
|
||||
recipientName: string;
|
||||
|
||||
@Column({ name: 'recipient_id', type: 'uuid', nullable: true })
|
||||
recipientId: string;
|
||||
|
||||
// Supporting documents
|
||||
@Column({ name: 'attachment_ids', type: 'jsonb', nullable: true })
|
||||
attachmentIds: string[];
|
||||
|
||||
// Financial account (for accounting integration)
|
||||
@Column({ name: 'account_id', type: 'uuid', nullable: true })
|
||||
accountId: string;
|
||||
|
||||
// Bank deposit info
|
||||
@Column({ name: 'bank_account', length: 50, nullable: true })
|
||||
bankAccount: string;
|
||||
|
||||
@Column({ name: 'deposit_slip_number', length: 50, nullable: true })
|
||||
depositSlipNumber: string;
|
||||
|
||||
// User who created the movement
|
||||
@Column({ name: 'created_by', type: 'uuid' })
|
||||
createdBy: string;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from './cash-movement.entity';
|
||||
export * from './cash-closing.entity';
|
||||
export * from './cash-count.entity';
|
||||
@ -1,5 +0,0 @@
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
export * from './controllers/cash.controller';
|
||||
export * from './routes/cash.routes';
|
||||
export * from './validation/cash.schema';
|
||||
@ -1,155 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { CashController } from '../controllers/cash.controller';
|
||||
import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware';
|
||||
import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware';
|
||||
import { idParamSchema } from '../../../shared/validation/common.schema';
|
||||
import {
|
||||
createMovementSchema,
|
||||
movementActionSchema,
|
||||
listMovementsQuerySchema,
|
||||
createClosingSchema,
|
||||
submitCashCountSchema,
|
||||
submitPaymentCountsSchema,
|
||||
closingActionSchema,
|
||||
rejectClosingSchema,
|
||||
reconcileClosingSchema,
|
||||
listClosingsQuerySchema,
|
||||
} from '../validation/cash.schema';
|
||||
|
||||
export function createCashRoutes(controller: CashController): Router {
|
||||
const router = Router();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
router.use(requireAuth);
|
||||
|
||||
// ==================== MOVEMENT ROUTES ====================
|
||||
|
||||
// Create movement
|
||||
router.post(
|
||||
'/movements',
|
||||
requirePermissions(['cash.movements.create']),
|
||||
validateBody(createMovementSchema),
|
||||
controller.createMovement
|
||||
);
|
||||
|
||||
// List movements
|
||||
router.get(
|
||||
'/movements',
|
||||
requirePermissions(['cash.movements.view']),
|
||||
validateQuery(listMovementsQuerySchema),
|
||||
controller.listMovements
|
||||
);
|
||||
|
||||
// Get movement by ID
|
||||
router.get(
|
||||
'/movements/:id',
|
||||
requirePermissions(['cash.movements.view']),
|
||||
controller.getMovement
|
||||
);
|
||||
|
||||
// Approve movement
|
||||
router.post(
|
||||
'/movements/:id/approve',
|
||||
requireRoles(['admin', 'supervisor', 'manager']),
|
||||
controller.approveMovement
|
||||
);
|
||||
|
||||
// Reject movement
|
||||
router.post(
|
||||
'/movements/:id/reject',
|
||||
requireRoles(['admin', 'supervisor', 'manager']),
|
||||
validateBody(movementActionSchema),
|
||||
controller.rejectMovement
|
||||
);
|
||||
|
||||
// Cancel movement
|
||||
router.post(
|
||||
'/movements/:id/cancel',
|
||||
requirePermissions(['cash.movements.cancel']),
|
||||
validateBody(movementActionSchema),
|
||||
controller.cancelMovement
|
||||
);
|
||||
|
||||
// Get session summary
|
||||
router.get(
|
||||
'/sessions/:sessionId/summary',
|
||||
requirePermissions(['cash.movements.view']),
|
||||
controller.getSessionSummary
|
||||
);
|
||||
|
||||
// ==================== CLOSING ROUTES ====================
|
||||
|
||||
// Create closing
|
||||
router.post(
|
||||
'/closings',
|
||||
requirePermissions(['cash.closings.create']),
|
||||
validateBody(createClosingSchema),
|
||||
controller.createClosing
|
||||
);
|
||||
|
||||
// List closings
|
||||
router.get(
|
||||
'/closings',
|
||||
requirePermissions(['cash.closings.view']),
|
||||
validateQuery(listClosingsQuerySchema),
|
||||
controller.listClosings
|
||||
);
|
||||
|
||||
// Get closing by ID
|
||||
router.get(
|
||||
'/closings/:id',
|
||||
requirePermissions(['cash.closings.view']),
|
||||
controller.getClosing
|
||||
);
|
||||
|
||||
// Submit cash count
|
||||
router.post(
|
||||
'/closings/:id/count',
|
||||
requirePermissions(['cash.closings.count']),
|
||||
validateBody(submitCashCountSchema),
|
||||
controller.submitCashCount
|
||||
);
|
||||
|
||||
// Submit payment counts
|
||||
router.post(
|
||||
'/closings/:id/payments',
|
||||
requirePermissions(['cash.closings.count']),
|
||||
validateBody(submitPaymentCountsSchema),
|
||||
controller.submitPaymentCounts
|
||||
);
|
||||
|
||||
// Approve closing
|
||||
router.post(
|
||||
'/closings/:id/approve',
|
||||
requireRoles(['admin', 'supervisor', 'manager']),
|
||||
validateBody(closingActionSchema),
|
||||
controller.approveClosing
|
||||
);
|
||||
|
||||
// Reject closing
|
||||
router.post(
|
||||
'/closings/:id/reject',
|
||||
requireRoles(['admin', 'supervisor', 'manager']),
|
||||
validateBody(rejectClosingSchema),
|
||||
controller.rejectClosing
|
||||
);
|
||||
|
||||
// Reconcile closing
|
||||
router.post(
|
||||
'/closings/:id/reconcile',
|
||||
requireRoles(['admin', 'accountant']),
|
||||
validateBody(reconcileClosingSchema),
|
||||
controller.reconcileClosing
|
||||
);
|
||||
|
||||
// ==================== SUMMARY ROUTES ====================
|
||||
|
||||
// Get daily summary
|
||||
router.get(
|
||||
'/summary/daily',
|
||||
requirePermissions(['cash.reports.view']),
|
||||
controller.getDailySummary
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@ -1,568 +0,0 @@
|
||||
import { Repository, DataSource, Between } from 'typeorm';
|
||||
import { BaseService } from '../../../shared/services/base.service';
|
||||
import { ServiceResult, QueryOptions } from '../../../shared/types';
|
||||
import {
|
||||
CashClosing,
|
||||
ClosingStatus,
|
||||
ClosingType,
|
||||
} from '../entities/cash-closing.entity';
|
||||
import { CashCount, DenominationType } from '../entities/cash-count.entity';
|
||||
import { CashMovement, MovementStatus } from '../entities/cash-movement.entity';
|
||||
|
||||
export interface CreateClosingInput {
|
||||
branchId: string;
|
||||
registerId?: string;
|
||||
sessionId?: string;
|
||||
type: ClosingType;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
openingBalance: number;
|
||||
closingNotes?: string;
|
||||
}
|
||||
|
||||
export interface ClosingQueryOptions extends QueryOptions {
|
||||
branchId?: string;
|
||||
sessionId?: string;
|
||||
status?: ClosingStatus;
|
||||
type?: ClosingType;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}
|
||||
|
||||
export interface DenominationCount {
|
||||
type: DenominationType;
|
||||
denomination: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface PaymentTotals {
|
||||
cash: number;
|
||||
card: number;
|
||||
transfer: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
export class CashClosingService extends BaseService<CashClosing> {
|
||||
constructor(
|
||||
repository: Repository<CashClosing>,
|
||||
private readonly countRepository: Repository<CashCount>,
|
||||
private readonly movementRepository: Repository<CashMovement>,
|
||||
private readonly dataSource: DataSource
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate closing number
|
||||
*/
|
||||
private async generateClosingNumber(
|
||||
tenantId: string,
|
||||
branchId: string,
|
||||
type: ClosingType
|
||||
): Promise<string> {
|
||||
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<ServiceResult<CashClosing>> {
|
||||
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<ServiceResult<CashClosing>> {
|
||||
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<ServiceResult<CashClosing>> {
|
||||
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<ServiceResult<CashClosing>> {
|
||||
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<ServiceResult<CashClosing>> {
|
||||
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<ServiceResult<CashClosing>> {
|
||||
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<CashClosing | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,398 +0,0 @@
|
||||
import { Repository, DataSource, Between, In } from 'typeorm';
|
||||
import { BaseService } from '../../../shared/services/base.service';
|
||||
import { ServiceResult, QueryOptions } from '../../../shared/types';
|
||||
import {
|
||||
CashMovement,
|
||||
MovementType,
|
||||
MovementReason,
|
||||
MovementStatus,
|
||||
} from '../entities/cash-movement.entity';
|
||||
|
||||
export interface CreateMovementInput {
|
||||
branchId: string;
|
||||
registerId: string;
|
||||
sessionId: string;
|
||||
type: MovementType;
|
||||
reason?: MovementReason;
|
||||
amount: number;
|
||||
description: string;
|
||||
referenceType?: string;
|
||||
referenceId?: string;
|
||||
referenceNumber?: string;
|
||||
requiresApproval?: boolean;
|
||||
recipientName?: string;
|
||||
recipientId?: string;
|
||||
accountId?: string;
|
||||
bankAccount?: string;
|
||||
depositSlipNumber?: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
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<CashMovement> {
|
||||
constructor(
|
||||
repository: Repository<CashMovement>,
|
||||
private readonly dataSource: DataSource
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique movement number
|
||||
*/
|
||||
private async generateMovementNumber(tenantId: string, branchId: string): Promise<string> {
|
||||
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<number> {
|
||||
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<ServiceResult<CashMovement>> {
|
||||
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<ServiceResult<CashMovement>> {
|
||||
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<ServiceResult<CashMovement>> {
|
||||
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<ServiceResult<CashMovement>> {
|
||||
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<number> {
|
||||
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<MovementType, number>;
|
||||
byReason: Record<MovementReason, number>;
|
||||
pendingCount: number;
|
||||
}> {
|
||||
const movements = await this.repository.find({
|
||||
where: { tenantId, sessionId, status: MovementStatus.APPROVED },
|
||||
});
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
const byReason: Record<string, number> = {};
|
||||
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<MovementType, number>,
|
||||
byReason: byReason as Record<MovementReason, number>,
|
||||
pendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get movements by date range for reports
|
||||
*/
|
||||
async getMovementsForReport(
|
||||
tenantId: string,
|
||||
branchId: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
types?: MovementType[]
|
||||
): Promise<CashMovement[]> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from './cash-movement.service';
|
||||
export * from './cash-closing.service';
|
||||
@ -1,160 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
uuidSchema,
|
||||
moneySchema,
|
||||
paginationSchema,
|
||||
} from '../../../shared/validation/common.schema';
|
||||
|
||||
// Enums
|
||||
export const movementTypeEnum = z.enum([
|
||||
'cash_in',
|
||||
'cash_out',
|
||||
'deposit',
|
||||
'withdrawal',
|
||||
'adjustment',
|
||||
'opening',
|
||||
'closing',
|
||||
]);
|
||||
|
||||
export const movementReasonEnum = z.enum([
|
||||
'change_fund',
|
||||
'petty_cash',
|
||||
'bank_deposit',
|
||||
'supplier_payment',
|
||||
'expense',
|
||||
'salary_advance',
|
||||
'correction',
|
||||
'other',
|
||||
]);
|
||||
|
||||
export const movementStatusEnum = z.enum([
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
export const closingTypeEnum = z.enum(['shift', 'daily', 'weekly', 'monthly']);
|
||||
export const closingStatusEnum = z.enum([
|
||||
'in_progress',
|
||||
'pending_review',
|
||||
'approved',
|
||||
'rejected',
|
||||
'reconciled',
|
||||
]);
|
||||
|
||||
export const denominationTypeEnum = z.enum(['bill', 'coin']);
|
||||
|
||||
// ==================== MOVEMENT SCHEMAS ====================
|
||||
|
||||
// Create movement schema
|
||||
export const createMovementSchema = z.object({
|
||||
branchId: uuidSchema,
|
||||
registerId: uuidSchema,
|
||||
sessionId: uuidSchema,
|
||||
type: movementTypeEnum,
|
||||
reason: movementReasonEnum.optional(),
|
||||
amount: moneySchema.positive('Amount must be positive'),
|
||||
description: z.string().min(1, 'Description is required').max(500),
|
||||
referenceType: z.string().max(50).optional(),
|
||||
referenceId: uuidSchema.optional(),
|
||||
referenceNumber: z.string().max(50).optional(),
|
||||
requiresApproval: z.boolean().optional(),
|
||||
recipientName: z.string().max(200).optional(),
|
||||
recipientId: uuidSchema.optional(),
|
||||
accountId: uuidSchema.optional(),
|
||||
bankAccount: z.string().max(50).optional(),
|
||||
depositSlipNumber: z.string().max(50).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
// Approve/reject movement schema
|
||||
export const movementActionSchema = z.object({
|
||||
reason: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
// List movements query schema
|
||||
export const listMovementsQuerySchema = paginationSchema.extend({
|
||||
branchId: uuidSchema.optional(),
|
||||
sessionId: uuidSchema.optional(),
|
||||
registerId: uuidSchema.optional(),
|
||||
type: movementTypeEnum.optional(),
|
||||
reason: movementReasonEnum.optional(),
|
||||
status: movementStatusEnum.optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
// ==================== CLOSING SCHEMAS ====================
|
||||
|
||||
// Create closing schema
|
||||
export const createClosingSchema = z.object({
|
||||
branchId: uuidSchema,
|
||||
registerId: uuidSchema.optional(),
|
||||
sessionId: uuidSchema.optional(),
|
||||
type: closingTypeEnum,
|
||||
periodStart: z.coerce.date(),
|
||||
periodEnd: z.coerce.date(),
|
||||
openingBalance: moneySchema.default(0),
|
||||
closingNotes: z.string().max(1000).optional(),
|
||||
}).refine(data => data.periodStart <= data.periodEnd, {
|
||||
message: 'Period start must be before or equal to period end',
|
||||
path: ['periodEnd'],
|
||||
});
|
||||
|
||||
// Denomination count schema
|
||||
export const denominationCountSchema = z.object({
|
||||
type: denominationTypeEnum,
|
||||
denomination: z.number().positive(),
|
||||
quantity: z.number().int().min(0),
|
||||
});
|
||||
|
||||
// Submit cash count schema
|
||||
export const submitCashCountSchema = z.object({
|
||||
denominations: z.array(denominationCountSchema).min(1, 'At least one denomination is required'),
|
||||
});
|
||||
|
||||
// Submit payment counts schema
|
||||
export const submitPaymentCountsSchema = z.object({
|
||||
cash: moneySchema.optional(),
|
||||
card: moneySchema.default(0),
|
||||
transfer: moneySchema.default(0),
|
||||
other: moneySchema.default(0),
|
||||
});
|
||||
|
||||
// Closing action schema (approve/reject)
|
||||
export const closingActionSchema = z.object({
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
// Reject closing schema (requires notes)
|
||||
export const rejectClosingSchema = z.object({
|
||||
notes: z.string().min(1, 'Rejection reason is required').max(1000),
|
||||
});
|
||||
|
||||
// Reconcile closing schema
|
||||
export const reconcileClosingSchema = z.object({
|
||||
depositAmount: moneySchema.positive('Deposit amount must be positive'),
|
||||
depositReference: z.string().min(1).max(100),
|
||||
depositDate: z.coerce.date(),
|
||||
});
|
||||
|
||||
// List closings query schema
|
||||
export const listClosingsQuerySchema = paginationSchema.extend({
|
||||
branchId: uuidSchema.optional(),
|
||||
sessionId: uuidSchema.optional(),
|
||||
status: closingStatusEnum.optional(),
|
||||
type: closingTypeEnum.optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
// Types
|
||||
export type CreateMovementInput = z.infer<typeof createMovementSchema>;
|
||||
export type ListMovementsQuery = z.infer<typeof listMovementsQuerySchema>;
|
||||
export type CreateClosingInput = z.infer<typeof createClosingSchema>;
|
||||
export type SubmitCashCountInput = z.infer<typeof submitCashCountSchema>;
|
||||
export type SubmitPaymentCountsInput = z.infer<typeof submitPaymentCountsSchema>;
|
||||
export type ReconcileClosingInput = z.infer<typeof reconcileClosingSchema>;
|
||||
export type ListClosingsQuery = z.infer<typeof listClosingsQuerySchema>;
|
||||
@ -1,222 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { BaseController } from '../../../shared/controllers/base.controller';
|
||||
import { LoyaltyService, MembershipQueryOptions } from '../services/loyalty.service';
|
||||
|
||||
export class LoyaltyController extends BaseController {
|
||||
constructor(private readonly loyaltyService: LoyaltyService) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ==================== PROGRAM ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* Get active loyalty program
|
||||
* GET /loyalty/program
|
||||
*/
|
||||
getActiveProgram = async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
};
|
||||
}
|
||||
@ -1,161 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum MembershipStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
SUSPENDED = 'suspended',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
@Entity('customer_memberships', { schema: 'retail' })
|
||||
@Index(['tenantId', 'customerId', 'programId'], { unique: true })
|
||||
@Index(['tenantId', 'cardNumber'], { unique: true })
|
||||
@Index(['tenantId', 'status'])
|
||||
@Index(['tenantId', 'levelId'])
|
||||
export class CustomerMembership {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
// Customer (from erp-core partners)
|
||||
@Column({ name: 'customer_id', type: 'uuid' })
|
||||
customerId: string;
|
||||
|
||||
@Column({ name: 'program_id', type: 'uuid' })
|
||||
programId: string;
|
||||
|
||||
@Column({ name: 'level_id', type: 'uuid' })
|
||||
levelId: string;
|
||||
|
||||
@Column({ name: 'card_number', length: 20, unique: true })
|
||||
cardNumber: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MembershipStatus,
|
||||
default: MembershipStatus.ACTIVE,
|
||||
})
|
||||
status: MembershipStatus;
|
||||
|
||||
// Points
|
||||
@Column({ name: 'points_balance', type: 'int', default: 0 })
|
||||
pointsBalance: number;
|
||||
|
||||
@Column({ name: 'lifetime_points', type: 'int', default: 0 })
|
||||
lifetimePoints: number;
|
||||
|
||||
@Column({ name: 'points_redeemed', type: 'int', default: 0 })
|
||||
pointsRedeemed: number;
|
||||
|
||||
@Column({ name: 'points_expired', type: 'int', default: 0 })
|
||||
pointsExpired: number;
|
||||
|
||||
@Column({ name: 'points_expiring_soon', type: 'int', default: 0 })
|
||||
pointsExpiringSoon: number;
|
||||
|
||||
@Column({ name: 'next_expiration_date', type: 'date', nullable: true })
|
||||
nextExpirationDate: Date;
|
||||
|
||||
@Column({ name: 'next_expiration_points', type: 'int', default: 0 })
|
||||
nextExpirationPoints: number;
|
||||
|
||||
// Purchase stats
|
||||
@Column({ name: 'total_purchases', type: 'int', default: 0 })
|
||||
totalPurchases: number;
|
||||
|
||||
@Column({ name: 'total_spend', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalSpend: number;
|
||||
|
||||
@Column({ name: 'average_order_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
averageOrderValue: number;
|
||||
|
||||
// Period stats (for level qualification)
|
||||
@Column({ name: 'period_purchases', type: 'int', default: 0 })
|
||||
periodPurchases: number;
|
||||
|
||||
@Column({ name: 'period_spend', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
periodSpend: number;
|
||||
|
||||
@Column({ name: 'period_points', type: 'int', default: 0 })
|
||||
periodPoints: number;
|
||||
|
||||
@Column({ name: 'period_start_date', type: 'date', nullable: true })
|
||||
periodStartDate: Date;
|
||||
|
||||
@Column({ name: 'period_end_date', type: 'date', nullable: true })
|
||||
periodEndDate: Date;
|
||||
|
||||
// Level history
|
||||
@Column({ name: 'level_achieved_at', type: 'timestamp with time zone', nullable: true })
|
||||
levelAchievedAt: Date;
|
||||
|
||||
@Column({ name: 'level_expires_at', type: 'date', nullable: true })
|
||||
levelExpiresAt: Date;
|
||||
|
||||
@Column({ name: 'previous_level_id', type: 'uuid', nullable: true })
|
||||
previousLevelId: string;
|
||||
|
||||
// Dates
|
||||
@Column({ name: 'enrolled_at', type: 'timestamp with time zone' })
|
||||
enrolledAt: Date;
|
||||
|
||||
@Column({ name: 'last_activity_at', type: 'timestamp with time zone', nullable: true })
|
||||
lastActivityAt: Date;
|
||||
|
||||
@Column({ name: 'last_purchase_at', type: 'timestamp with time zone', nullable: true })
|
||||
lastPurchaseAt: Date;
|
||||
|
||||
@Column({ name: 'last_redemption_at', type: 'timestamp with time zone', nullable: true })
|
||||
lastRedemptionAt: Date;
|
||||
|
||||
// Referral
|
||||
@Column({ name: 'referral_code', length: 20, nullable: true })
|
||||
referralCode: string;
|
||||
|
||||
@Column({ name: 'referred_by_id', type: 'uuid', nullable: true })
|
||||
referredById: string;
|
||||
|
||||
@Column({ name: 'referrals_count', type: 'int', default: 0 })
|
||||
referralsCount: number;
|
||||
|
||||
// Communication preferences
|
||||
@Column({ name: 'email_notifications', type: 'boolean', default: true })
|
||||
emailNotifications: boolean;
|
||||
|
||||
@Column({ name: 'sms_notifications', type: 'boolean', default: false })
|
||||
smsNotifications: boolean;
|
||||
|
||||
@Column({ name: 'push_notifications', type: 'boolean', default: true })
|
||||
pushNotifications: boolean;
|
||||
|
||||
// Favorite branch
|
||||
@Column({ name: 'favorite_branch_id', type: 'uuid', nullable: true })
|
||||
favoriteBranchId: string;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export * from './loyalty-program.entity';
|
||||
export * from './membership-level.entity';
|
||||
export * from './loyalty-transaction.entity';
|
||||
export * from './customer-membership.entity';
|
||||
@ -1,161 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { MembershipLevel } from './membership-level.entity';
|
||||
|
||||
export enum ProgramStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
SUSPENDED = 'suspended',
|
||||
}
|
||||
|
||||
export enum PointsCalculation {
|
||||
FIXED_PER_PURCHASE = 'fixed_per_purchase',
|
||||
PERCENTAGE_OF_AMOUNT = 'percentage_of_amount',
|
||||
POINTS_PER_CURRENCY = 'points_per_currency',
|
||||
}
|
||||
|
||||
@Entity('loyalty_programs', { schema: 'retail' })
|
||||
@Index(['tenantId', 'status'])
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
export class LoyaltyProgram {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 20, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ProgramStatus,
|
||||
default: ProgramStatus.ACTIVE,
|
||||
})
|
||||
status: ProgramStatus;
|
||||
|
||||
// Points configuration
|
||||
@Column({
|
||||
name: 'points_calculation',
|
||||
type: 'enum',
|
||||
enum: PointsCalculation,
|
||||
default: PointsCalculation.POINTS_PER_CURRENCY,
|
||||
})
|
||||
pointsCalculation: PointsCalculation;
|
||||
|
||||
@Column({ name: 'points_per_currency', type: 'decimal', precision: 10, scale: 4, default: 1 })
|
||||
pointsPerCurrency: number; // e.g., 1 point per $10 MXN
|
||||
|
||||
@Column({ name: 'currency_per_point', type: 'decimal', precision: 10, scale: 2, default: 10 })
|
||||
currencyPerPoint: number; // e.g., $10 MXN = 1 point
|
||||
|
||||
@Column({ name: 'minimum_purchase', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
minimumPurchase: number;
|
||||
|
||||
@Column({ name: 'round_points', type: 'boolean', default: true })
|
||||
roundPoints: boolean;
|
||||
|
||||
@Column({ name: 'round_direction', length: 10, default: 'down' })
|
||||
roundDirection: 'up' | 'down' | 'nearest';
|
||||
|
||||
// Redemption configuration
|
||||
@Column({ name: 'points_value', type: 'decimal', precision: 10, scale: 4, default: 0.1 })
|
||||
pointsValue: number; // Value of 1 point in currency (e.g., 1 point = $0.10)
|
||||
|
||||
@Column({ name: 'min_points_redemption', type: 'int', default: 100 })
|
||||
minPointsRedemption: number;
|
||||
|
||||
@Column({ name: 'max_redemption_percent', type: 'decimal', precision: 5, scale: 2, default: 100 })
|
||||
maxRedemptionPercent: number; // Max % of purchase that can be paid with points
|
||||
|
||||
@Column({ name: 'allow_partial_redemption', type: 'boolean', default: true })
|
||||
allowPartialRedemption: boolean;
|
||||
|
||||
// Expiration
|
||||
@Column({ name: 'points_expire', type: 'boolean', default: true })
|
||||
pointsExpire: boolean;
|
||||
|
||||
@Column({ name: 'expiration_months', type: 'int', default: 12 })
|
||||
expirationMonths: number;
|
||||
|
||||
@Column({ name: 'expiration_policy', length: 20, default: 'fifo' })
|
||||
expirationPolicy: 'fifo' | 'lifo' | 'oldest_first';
|
||||
|
||||
// Bonus configuration
|
||||
@Column({ name: 'welcome_bonus', type: 'int', default: 0 })
|
||||
welcomeBonus: number;
|
||||
|
||||
@Column({ name: 'birthday_bonus', type: 'int', default: 0 })
|
||||
birthdayBonus: number;
|
||||
|
||||
@Column({ name: 'referral_bonus', type: 'int', default: 0 })
|
||||
referralBonus: number;
|
||||
|
||||
@Column({ name: 'referee_bonus', type: 'int', default: 0 })
|
||||
refereeBonus: number;
|
||||
|
||||
// Multipliers
|
||||
@Column({ name: 'double_points_days', type: 'jsonb', nullable: true })
|
||||
doublePointsDays: string[]; // ['monday', 'tuesday', etc.]
|
||||
|
||||
@Column({ name: 'category_multipliers', type: 'jsonb', nullable: true })
|
||||
categoryMultipliers: {
|
||||
categoryId: string;
|
||||
multiplier: number;
|
||||
}[];
|
||||
|
||||
// Restrictions
|
||||
@Column({ name: 'excluded_categories', type: 'jsonb', nullable: true })
|
||||
excludedCategories: string[];
|
||||
|
||||
@Column({ name: 'excluded_products', type: 'jsonb', nullable: true })
|
||||
excludedProducts: string[];
|
||||
|
||||
@Column({ name: 'included_branches', type: 'jsonb', nullable: true })
|
||||
includedBranches: string[]; // null = all branches
|
||||
|
||||
// Valid dates
|
||||
@Column({ name: 'valid_from', type: 'date', nullable: true })
|
||||
validFrom: Date;
|
||||
|
||||
@Column({ name: 'valid_until', type: 'date', nullable: true })
|
||||
validUntil: Date;
|
||||
|
||||
// Terms
|
||||
@Column({ name: 'terms_and_conditions', type: 'text', nullable: true })
|
||||
termsAndConditions: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@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[];
|
||||
}
|
||||
@ -1,144 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum TransactionType {
|
||||
EARN = 'earn',
|
||||
REDEEM = 'redeem',
|
||||
EXPIRE = 'expire',
|
||||
ADJUST = 'adjust',
|
||||
BONUS = 'bonus',
|
||||
TRANSFER_IN = 'transfer_in',
|
||||
TRANSFER_OUT = 'transfer_out',
|
||||
REFUND = 'refund',
|
||||
}
|
||||
|
||||
export enum TransactionStatus {
|
||||
PENDING = 'pending',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
REVERSED = 'reversed',
|
||||
}
|
||||
|
||||
@Entity('loyalty_transactions', { schema: 'retail' })
|
||||
@Index(['tenantId', 'customerId', 'createdAt'])
|
||||
@Index(['tenantId', 'programId', 'type'])
|
||||
@Index(['tenantId', 'membershipId'])
|
||||
@Index(['tenantId', 'orderId'])
|
||||
export class LoyaltyTransaction {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'program_id', type: 'uuid' })
|
||||
programId: string;
|
||||
|
||||
@Column({ name: 'membership_id', type: 'uuid' })
|
||||
membershipId: string;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid' })
|
||||
customerId: string;
|
||||
|
||||
@Column({ length: 30, unique: true })
|
||||
number: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TransactionType,
|
||||
})
|
||||
type: TransactionType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TransactionStatus,
|
||||
default: TransactionStatus.COMPLETED,
|
||||
})
|
||||
status: TransactionStatus;
|
||||
|
||||
// Points
|
||||
@Column({ type: 'int' })
|
||||
points: number; // positive for earn, negative for redeem
|
||||
|
||||
@Column({ name: 'points_balance_after', type: 'int' })
|
||||
pointsBalanceAfter: number;
|
||||
|
||||
// Value
|
||||
@Column({ name: 'monetary_value', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
monetaryValue: number;
|
||||
|
||||
@Column({ name: 'purchase_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
purchaseAmount: number;
|
||||
|
||||
// Reference
|
||||
@Column({ name: 'order_id', type: 'uuid', nullable: true })
|
||||
orderId: string;
|
||||
|
||||
@Column({ name: 'order_number', length: 30, nullable: true })
|
||||
orderNumber: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
|
||||
branchId: string;
|
||||
|
||||
// Bonus type (for bonus transactions)
|
||||
@Column({ name: 'bonus_type', length: 30, nullable: true })
|
||||
bonusType: 'welcome' | 'birthday' | 'referral' | 'referee' | 'campaign' | 'manual';
|
||||
|
||||
@Column({ name: 'campaign_id', type: 'uuid', nullable: true })
|
||||
campaignId: string;
|
||||
|
||||
// Multipliers applied
|
||||
@Column({ name: 'base_points', type: 'int', nullable: true })
|
||||
basePoints: number;
|
||||
|
||||
@Column({ name: 'multiplier_applied', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
multiplierApplied: number;
|
||||
|
||||
@Column({ name: 'multiplier_reason', length: 100, nullable: true })
|
||||
multiplierReason: string;
|
||||
|
||||
// Expiration
|
||||
@Column({ name: 'expires_at', type: 'date', nullable: true })
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ name: 'expired', type: 'boolean', default: false })
|
||||
expired: boolean;
|
||||
|
||||
// For transfers
|
||||
@Column({ name: 'transfer_to_customer_id', type: 'uuid', nullable: true })
|
||||
transferToCustomerId: string;
|
||||
|
||||
@Column({ name: 'transfer_from_customer_id', type: 'uuid', nullable: true })
|
||||
transferFromCustomerId: string;
|
||||
|
||||
// Description
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
// Reversal
|
||||
@Column({ name: 'reversed_by_id', type: 'uuid', nullable: true })
|
||||
reversedById: string;
|
||||
|
||||
@Column({ name: 'reversal_of_id', type: 'uuid', nullable: true })
|
||||
reversalOfId: string;
|
||||
|
||||
@Column({ name: 'reversal_reason', length: 255, nullable: true })
|
||||
reversalReason: string;
|
||||
|
||||
// User
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { LoyaltyProgram } from './loyalty-program.entity';
|
||||
|
||||
export enum LevelStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
}
|
||||
|
||||
@Entity('membership_levels', { schema: 'retail' })
|
||||
@Index(['tenantId', 'programId', 'level'])
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
export class MembershipLevel {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'program_id', type: 'uuid' })
|
||||
programId: string;
|
||||
|
||||
@Column({ length: 20, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ length: 50 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
level: number; // 0 = base level, higher = better
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: LevelStatus,
|
||||
default: LevelStatus.ACTIVE,
|
||||
})
|
||||
status: LevelStatus;
|
||||
|
||||
// Qualification criteria
|
||||
@Column({ name: 'min_points_required', type: 'int', default: 0 })
|
||||
minPointsRequired: number;
|
||||
|
||||
@Column({ name: 'min_purchases_required', type: 'int', default: 0 })
|
||||
minPurchasesRequired: number;
|
||||
|
||||
@Column({ name: 'min_spend_required', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
minSpendRequired: number;
|
||||
|
||||
@Column({ name: 'qualification_period_months', type: 'int', default: 12 })
|
||||
qualificationPeriodMonths: number;
|
||||
|
||||
// Benefits
|
||||
@Column({ name: 'points_multiplier', type: 'decimal', precision: 5, scale: 2, default: 1 })
|
||||
pointsMultiplier: number;
|
||||
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ name: 'free_shipping', type: 'boolean', default: false })
|
||||
freeShipping: boolean;
|
||||
|
||||
@Column({ name: 'free_shipping_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
freeShippingThreshold: number;
|
||||
|
||||
@Column({ name: 'priority_support', type: 'boolean', default: false })
|
||||
prioritySupport: boolean;
|
||||
|
||||
@Column({ name: 'early_access', type: 'boolean', default: false })
|
||||
earlyAccess: boolean;
|
||||
|
||||
@Column({ name: 'exclusive_promotions', type: 'boolean', default: false })
|
||||
exclusivePromotions: boolean;
|
||||
|
||||
@Column({ name: 'birthday_multiplier', type: 'decimal', precision: 5, scale: 2, default: 1 })
|
||||
birthdayMultiplier: number;
|
||||
|
||||
// Custom benefits
|
||||
@Column({ name: 'custom_benefits', type: 'jsonb', nullable: true })
|
||||
customBenefits: {
|
||||
name: string;
|
||||
description: string;
|
||||
value?: string;
|
||||
}[];
|
||||
|
||||
// Display
|
||||
@Column({ length: 7, default: '#000000' })
|
||||
color: string;
|
||||
|
||||
@Column({ name: 'icon_url', length: 255, nullable: true })
|
||||
iconUrl: string;
|
||||
|
||||
@Column({ name: 'badge_url', length: 255, nullable: true })
|
||||
badgeUrl: string;
|
||||
|
||||
// Retention
|
||||
@Column({ name: 'retention_months', type: 'int', default: 12 })
|
||||
retentionMonths: number;
|
||||
|
||||
@Column({ name: 'downgrade_level_id', type: 'uuid', nullable: true })
|
||||
downgradeLevelId: string;
|
||||
|
||||
// Auto-upgrade
|
||||
@Column({ name: 'auto_upgrade', type: 'boolean', default: true })
|
||||
autoUpgrade: boolean;
|
||||
|
||||
@Column({ name: 'upgrade_notification', type: 'boolean', default: true })
|
||||
upgradeNotification: boolean;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => LoyaltyProgram, (program) => program.levels)
|
||||
@JoinColumn({ name: 'program_id' })
|
||||
program: LoyaltyProgram;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
export * from './controllers/loyalty.controller';
|
||||
export * from './routes/loyalty.routes';
|
||||
export * from './validation/customers.schema';
|
||||
@ -1,113 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { LoyaltyController } from '../controllers/loyalty.controller';
|
||||
import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware';
|
||||
import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware';
|
||||
import {
|
||||
enrollCustomerSchema,
|
||||
listMembershipsQuerySchema,
|
||||
earnPointsSchema,
|
||||
redeemPointsSchema,
|
||||
adjustPointsSchema,
|
||||
calculatePointsSchema,
|
||||
lookupByCardSchema,
|
||||
listTransactionsQuerySchema,
|
||||
} from '../validation/customers.schema';
|
||||
|
||||
export function createLoyaltyRoutes(controller: LoyaltyController): Router {
|
||||
const router = Router();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
router.use(requireAuth);
|
||||
|
||||
// ==================== PROGRAM ROUTES ====================
|
||||
|
||||
// Get active program
|
||||
router.get(
|
||||
'/program',
|
||||
requirePermissions(['loyalty.view']),
|
||||
controller.getActiveProgram
|
||||
);
|
||||
|
||||
// ==================== MEMBERSHIP ROUTES ====================
|
||||
|
||||
// Enroll customer
|
||||
router.post(
|
||||
'/enroll',
|
||||
requirePermissions(['loyalty.enroll']),
|
||||
validateBody(enrollCustomerSchema),
|
||||
controller.enrollCustomer
|
||||
);
|
||||
|
||||
// List memberships
|
||||
router.get(
|
||||
'/memberships',
|
||||
requirePermissions(['loyalty.view']),
|
||||
validateQuery(listMembershipsQuerySchema),
|
||||
controller.listMemberships
|
||||
);
|
||||
|
||||
// Get membership by customer
|
||||
router.get(
|
||||
'/memberships/customer/:customerId',
|
||||
requirePermissions(['loyalty.view']),
|
||||
controller.getMembershipByCustomer
|
||||
);
|
||||
|
||||
// Get membership by card
|
||||
router.get(
|
||||
'/memberships/card/:cardNumber',
|
||||
requirePermissions(['loyalty.view']),
|
||||
controller.getMembershipByCard
|
||||
);
|
||||
|
||||
// Get expiring points
|
||||
router.get(
|
||||
'/memberships/:membershipId/expiring',
|
||||
requirePermissions(['loyalty.view']),
|
||||
controller.getExpiringPoints
|
||||
);
|
||||
|
||||
// Get transaction history
|
||||
router.get(
|
||||
'/memberships/:membershipId/transactions',
|
||||
requirePermissions(['loyalty.view']),
|
||||
validateQuery(listTransactionsQuerySchema),
|
||||
controller.getTransactionHistory
|
||||
);
|
||||
|
||||
// ==================== POINTS ROUTES ====================
|
||||
|
||||
// Calculate points preview
|
||||
router.post(
|
||||
'/points/calculate',
|
||||
requirePermissions(['loyalty.view']),
|
||||
validateBody(calculatePointsSchema),
|
||||
controller.calculatePoints
|
||||
);
|
||||
|
||||
// Earn points
|
||||
router.post(
|
||||
'/points/earn',
|
||||
requirePermissions(['loyalty.earn']),
|
||||
validateBody(earnPointsSchema),
|
||||
controller.earnPoints
|
||||
);
|
||||
|
||||
// Redeem points
|
||||
router.post(
|
||||
'/points/redeem',
|
||||
requirePermissions(['loyalty.redeem']),
|
||||
validateBody(redeemPointsSchema),
|
||||
controller.redeemPoints
|
||||
);
|
||||
|
||||
// Adjust points
|
||||
router.post(
|
||||
'/points/adjust',
|
||||
requireRoles(['admin', 'manager']),
|
||||
validateBody(adjustPointsSchema),
|
||||
controller.adjustPoints
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from './loyalty.service';
|
||||
@ -1,842 +0,0 @@
|
||||
import { Repository, DataSource, Between, LessThanOrEqual, MoreThan } from 'typeorm';
|
||||
import { ServiceResult, QueryOptions } from '../../../shared/types';
|
||||
import { LoyaltyProgram, ProgramStatus, PointsCalculation } from '../entities/loyalty-program.entity';
|
||||
import { MembershipLevel, LevelStatus } from '../entities/membership-level.entity';
|
||||
import { CustomerMembership, MembershipStatus } from '../entities/customer-membership.entity';
|
||||
import { LoyaltyTransaction, TransactionType, TransactionStatus } from '../entities/loyalty-transaction.entity';
|
||||
|
||||
export interface EnrollCustomerInput {
|
||||
customerId: string;
|
||||
programId: string;
|
||||
referredById?: string;
|
||||
emailNotifications?: boolean;
|
||||
smsNotifications?: boolean;
|
||||
}
|
||||
|
||||
export interface EarnPointsInput {
|
||||
membershipId: string;
|
||||
purchaseAmount: number;
|
||||
orderId?: string;
|
||||
orderNumber?: string;
|
||||
branchId?: string;
|
||||
categoryMultipliers?: { categoryId: string; amount: number }[];
|
||||
}
|
||||
|
||||
export interface RedeemPointsInput {
|
||||
membershipId: string;
|
||||
points: number;
|
||||
orderId?: string;
|
||||
orderNumber?: string;
|
||||
branchId?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AdjustPointsInput {
|
||||
membershipId: string;
|
||||
points: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface MembershipQueryOptions extends QueryOptions {
|
||||
programId?: string;
|
||||
levelId?: string;
|
||||
status?: MembershipStatus;
|
||||
customerId?: string;
|
||||
}
|
||||
|
||||
export class LoyaltyService {
|
||||
constructor(
|
||||
private readonly programRepository: Repository<LoyaltyProgram>,
|
||||
private readonly levelRepository: Repository<MembershipLevel>,
|
||||
private readonly membershipRepository: Repository<CustomerMembership>,
|
||||
private readonly transactionRepository: Repository<LoyaltyTransaction>,
|
||||
private readonly dataSource: DataSource
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate a unique card number
|
||||
*/
|
||||
private async generateCardNumber(tenantId: string): Promise<string> {
|
||||
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<string> {
|
||||
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<LoyaltyProgram | null> {
|
||||
return this.programRepository.findOne({
|
||||
where: {
|
||||
tenantId,
|
||||
status: ProgramStatus.ACTIVE,
|
||||
},
|
||||
relations: ['levels'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base level for a program
|
||||
*/
|
||||
async getBaseLevel(tenantId: string, programId: string): Promise<MembershipLevel | null> {
|
||||
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<ServiceResult<CustomerMembership>> {
|
||||
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<ServiceResult<LoyaltyTransaction>> {
|
||||
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<ServiceResult<{ transaction: LoyaltyTransaction; monetaryValue: number }>> {
|
||||
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<ServiceResult<LoyaltyTransaction>> {
|
||||
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<boolean> {
|
||||
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<CustomerMembership | null> {
|
||||
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<CustomerMembership | null> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@ -1,179 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
uuidSchema,
|
||||
moneySchema,
|
||||
paginationSchema,
|
||||
} from '../../../shared/validation/common.schema';
|
||||
|
||||
// Enums
|
||||
export const programStatusEnum = z.enum(['active', 'inactive', 'suspended']);
|
||||
export const membershipStatusEnum = z.enum(['active', 'inactive', 'suspended', 'expired']);
|
||||
export const transactionTypeEnum = z.enum([
|
||||
'earn',
|
||||
'redeem',
|
||||
'expire',
|
||||
'adjust',
|
||||
'bonus',
|
||||
'transfer_in',
|
||||
'transfer_out',
|
||||
'refund',
|
||||
]);
|
||||
|
||||
// ==================== PROGRAM SCHEMAS ====================
|
||||
|
||||
// Create program schema
|
||||
export const createProgramSchema = z.object({
|
||||
code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'),
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(1000).optional(),
|
||||
pointsCalculation: z.enum(['fixed_per_purchase', 'percentage_of_amount', 'points_per_currency']).default('points_per_currency'),
|
||||
pointsPerCurrency: z.number().positive().default(1),
|
||||
currencyPerPoint: z.number().positive().default(10),
|
||||
minimumPurchase: moneySchema.default(0),
|
||||
roundPoints: z.boolean().default(true),
|
||||
roundDirection: z.enum(['up', 'down', 'nearest']).default('down'),
|
||||
pointsValue: z.number().positive().default(0.1),
|
||||
minPointsRedemption: z.number().int().positive().default(100),
|
||||
maxRedemptionPercent: z.number().min(0).max(100).default(100),
|
||||
allowPartialRedemption: z.boolean().default(true),
|
||||
pointsExpire: z.boolean().default(true),
|
||||
expirationMonths: z.number().int().min(1).max(120).default(12),
|
||||
welcomeBonus: z.number().int().min(0).default(0),
|
||||
birthdayBonus: z.number().int().min(0).default(0),
|
||||
referralBonus: z.number().int().min(0).default(0),
|
||||
refereeBonus: z.number().int().min(0).default(0),
|
||||
validFrom: z.coerce.date().optional(),
|
||||
validUntil: z.coerce.date().optional(),
|
||||
termsAndConditions: z.string().max(10000).optional(),
|
||||
});
|
||||
|
||||
// Update program schema
|
||||
export const updateProgramSchema = createProgramSchema.partial().omit({ code: true });
|
||||
|
||||
// ==================== LEVEL SCHEMAS ====================
|
||||
|
||||
// Create level schema
|
||||
export const createLevelSchema = z.object({
|
||||
programId: uuidSchema,
|
||||
code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'),
|
||||
name: z.string().min(1).max(50),
|
||||
description: z.string().max(500).optional(),
|
||||
level: z.number().int().min(0).default(0),
|
||||
minPointsRequired: z.number().int().min(0).default(0),
|
||||
minPurchasesRequired: z.number().int().min(0).default(0),
|
||||
minSpendRequired: moneySchema.default(0),
|
||||
qualificationPeriodMonths: z.number().int().min(1).max(24).default(12),
|
||||
pointsMultiplier: z.number().min(1).max(10).default(1),
|
||||
discountPercent: z.number().min(0).max(100).default(0),
|
||||
freeShipping: z.boolean().default(false),
|
||||
freeShippingThreshold: moneySchema.optional(),
|
||||
prioritySupport: z.boolean().default(false),
|
||||
earlyAccess: z.boolean().default(false),
|
||||
exclusivePromotions: z.boolean().default(false),
|
||||
birthdayMultiplier: z.number().min(1).max(10).default(1),
|
||||
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid color format').default('#000000'),
|
||||
retentionMonths: z.number().int().min(1).max(36).default(12),
|
||||
autoUpgrade: z.boolean().default(true),
|
||||
});
|
||||
|
||||
// Update level schema
|
||||
export const updateLevelSchema = createLevelSchema.partial().omit({ programId: true, code: true });
|
||||
|
||||
// ==================== MEMBERSHIP SCHEMAS ====================
|
||||
|
||||
// Enroll customer schema
|
||||
export const enrollCustomerSchema = z.object({
|
||||
customerId: uuidSchema,
|
||||
programId: uuidSchema,
|
||||
referredById: uuidSchema.optional(),
|
||||
emailNotifications: z.boolean().default(true),
|
||||
smsNotifications: z.boolean().default(false),
|
||||
});
|
||||
|
||||
// Update membership schema
|
||||
export const updateMembershipSchema = z.object({
|
||||
status: membershipStatusEnum.optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
smsNotifications: z.boolean().optional(),
|
||||
pushNotifications: z.boolean().optional(),
|
||||
favoriteBranchId: uuidSchema.optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
// List memberships query schema
|
||||
export const listMembershipsQuerySchema = paginationSchema.extend({
|
||||
programId: uuidSchema.optional(),
|
||||
levelId: uuidSchema.optional(),
|
||||
status: membershipStatusEnum.optional(),
|
||||
customerId: uuidSchema.optional(),
|
||||
});
|
||||
|
||||
// ==================== TRANSACTION SCHEMAS ====================
|
||||
|
||||
// Category amount schema (for multipliers)
|
||||
export const categoryAmountSchema = z.object({
|
||||
categoryId: uuidSchema,
|
||||
amount: moneySchema.positive(),
|
||||
});
|
||||
|
||||
// Earn points schema
|
||||
export const earnPointsSchema = z.object({
|
||||
membershipId: uuidSchema,
|
||||
purchaseAmount: moneySchema.positive('Purchase amount must be positive'),
|
||||
orderId: uuidSchema.optional(),
|
||||
orderNumber: z.string().max(30).optional(),
|
||||
branchId: uuidSchema.optional(),
|
||||
categoryMultipliers: z.array(categoryAmountSchema).optional(),
|
||||
});
|
||||
|
||||
// Redeem points schema
|
||||
export const redeemPointsSchema = z.object({
|
||||
membershipId: uuidSchema,
|
||||
points: z.number().int().positive('Points must be positive'),
|
||||
orderId: uuidSchema.optional(),
|
||||
orderNumber: z.string().max(30).optional(),
|
||||
branchId: uuidSchema.optional(),
|
||||
description: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
// Adjust points schema
|
||||
export const adjustPointsSchema = z.object({
|
||||
membershipId: uuidSchema,
|
||||
points: z.number().int().refine(v => v !== 0, 'Points cannot be zero'),
|
||||
reason: z.string().min(1, 'Reason is required').max(255),
|
||||
});
|
||||
|
||||
// List transactions query schema
|
||||
export const listTransactionsQuerySchema = paginationSchema.extend({
|
||||
membershipId: uuidSchema.optional(),
|
||||
type: transactionTypeEnum.optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
// ==================== LOOKUP SCHEMAS ====================
|
||||
|
||||
// Lookup by card number
|
||||
export const lookupByCardSchema = z.object({
|
||||
cardNumber: z.string().min(10).max(20),
|
||||
});
|
||||
|
||||
// Calculate points preview schema
|
||||
export const calculatePointsSchema = z.object({
|
||||
membershipId: uuidSchema,
|
||||
purchaseAmount: moneySchema.positive(),
|
||||
categoryMultipliers: z.array(categoryAmountSchema).optional(),
|
||||
});
|
||||
|
||||
// Types
|
||||
export type CreateProgramInput = z.infer<typeof createProgramSchema>;
|
||||
export type UpdateProgramInput = z.infer<typeof updateProgramSchema>;
|
||||
export type CreateLevelInput = z.infer<typeof createLevelSchema>;
|
||||
export type UpdateLevelInput = z.infer<typeof updateLevelSchema>;
|
||||
export type EnrollCustomerInput = z.infer<typeof enrollCustomerSchema>;
|
||||
export type UpdateMembershipInput = z.infer<typeof updateMembershipSchema>;
|
||||
export type EarnPointsInput = z.infer<typeof earnPointsSchema>;
|
||||
export type RedeemPointsInput = z.infer<typeof redeemPointsSchema>;
|
||||
export type AdjustPointsInput = z.infer<typeof adjustPointsSchema>;
|
||||
export type ListMembershipsQuery = z.infer<typeof listMembershipsQuerySchema>;
|
||||
export type ListTransactionsQuery = z.infer<typeof listTransactionsQuerySchema>;
|
||||
@ -1,156 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Cart } from './cart.entity';
|
||||
|
||||
@Entity('cart_items', { schema: 'retail' })
|
||||
@Index(['tenantId', 'cartId'])
|
||||
@Index(['tenantId', 'productId'])
|
||||
@Index(['tenantId', 'cartId', 'productId', 'variantId'], { unique: true })
|
||||
export class CartItem {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'cart_id', type: 'uuid' })
|
||||
cartId: string;
|
||||
|
||||
// Product (from erp-core)
|
||||
@Column({ name: 'product_id', type: 'uuid' })
|
||||
productId: string;
|
||||
|
||||
@Column({ name: 'product_code', length: 50 })
|
||||
productCode: string;
|
||||
|
||||
@Column({ name: 'product_name', length: 200 })
|
||||
productName: string;
|
||||
|
||||
@Column({ name: 'product_slug', length: 255, nullable: true })
|
||||
productSlug: string;
|
||||
|
||||
@Column({ name: 'product_image', length: 255, nullable: true })
|
||||
productImage: string;
|
||||
|
||||
// Variant
|
||||
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
|
||||
variantId: string;
|
||||
|
||||
@Column({ name: 'variant_name', length: 200, nullable: true })
|
||||
variantName: string;
|
||||
|
||||
@Column({ name: 'variant_sku', length: 50, nullable: true })
|
||||
variantSku: string;
|
||||
|
||||
// Variant options
|
||||
@Column({ name: 'variant_options', type: 'jsonb', nullable: true })
|
||||
variantOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
// Quantity
|
||||
@Column({ type: 'decimal', precision: 15, scale: 4, default: 1 })
|
||||
quantity: number;
|
||||
|
||||
// Pricing
|
||||
@Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 })
|
||||
unitPrice: number;
|
||||
|
||||
@Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 })
|
||||
originalPrice: number;
|
||||
|
||||
@Column({ name: 'price_list_id', type: 'uuid', nullable: true })
|
||||
priceListId: string;
|
||||
|
||||
// Discounts
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'promotion_id', type: 'uuid', nullable: true })
|
||||
promotionId: string;
|
||||
|
||||
@Column({ name: 'promotion_name', length: 100, nullable: true })
|
||||
promotionName: string;
|
||||
|
||||
// Tax
|
||||
@Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 })
|
||||
taxRate: number;
|
||||
|
||||
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
taxAmount: number;
|
||||
|
||||
@Column({ name: 'tax_included', type: 'boolean', default: true })
|
||||
taxIncluded: boolean;
|
||||
|
||||
// Totals
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
subtotal: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
total: number;
|
||||
|
||||
// Stock check
|
||||
@Column({ name: 'stock_available', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||
stockAvailable: number;
|
||||
|
||||
@Column({ name: 'is_available', type: 'boolean', default: true })
|
||||
isAvailable: boolean;
|
||||
|
||||
@Column({ name: 'availability_message', length: 255, nullable: true })
|
||||
availabilityMessage: string;
|
||||
|
||||
// Warehouse
|
||||
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||
warehouseId: string;
|
||||
|
||||
// Gift options
|
||||
@Column({ name: 'is_gift', type: 'boolean', default: false })
|
||||
isGift: boolean;
|
||||
|
||||
@Column({ name: 'gift_message', type: 'text', nullable: true })
|
||||
giftMessage: string;
|
||||
|
||||
// Custom options (personalization, etc.)
|
||||
@Column({ name: 'custom_options', type: 'jsonb', nullable: true })
|
||||
customOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
price?: number;
|
||||
}[];
|
||||
|
||||
// Saved for later
|
||||
@Column({ name: 'saved_for_later', type: 'boolean', default: false })
|
||||
savedForLater: boolean;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Cart, (cart) => cart.items)
|
||||
@JoinColumn({ name: 'cart_id' })
|
||||
cart: Cart;
|
||||
}
|
||||
@ -1,210 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { CartItem } from './cart-item.entity';
|
||||
|
||||
export enum CartStatus {
|
||||
ACTIVE = 'active',
|
||||
ABANDONED = 'abandoned',
|
||||
CONVERTED = 'converted',
|
||||
MERGED = 'merged',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
@Entity('carts', { schema: 'retail' })
|
||||
@Index(['tenantId', 'customerId'])
|
||||
@Index(['tenantId', 'sessionId'])
|
||||
@Index(['tenantId', 'status', 'updatedAt'])
|
||||
export class Cart {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
// Customer (if logged in, from erp-core partners)
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId: string;
|
||||
|
||||
// Session (for guest carts)
|
||||
@Column({ name: 'session_id', length: 100, nullable: true })
|
||||
sessionId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CartStatus,
|
||||
default: CartStatus.ACTIVE,
|
||||
})
|
||||
status: CartStatus;
|
||||
|
||||
// Currency
|
||||
@Column({ name: 'currency_code', length: 3, default: 'MXN' })
|
||||
currencyCode: string;
|
||||
|
||||
// Totals
|
||||
@Column({ name: 'items_count', type: 'int', default: 0 })
|
||||
itemsCount: number;
|
||||
|
||||
@Column({ name: 'items_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
itemsQuantity: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
subtotal: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
taxAmount: number;
|
||||
|
||||
@Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
shippingAmount: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
total: number;
|
||||
|
||||
// Coupon
|
||||
@Column({ name: 'coupon_id', type: 'uuid', nullable: true })
|
||||
couponId: string;
|
||||
|
||||
@Column({ name: 'coupon_code', length: 50, nullable: true })
|
||||
couponCode: string;
|
||||
|
||||
@Column({ name: 'coupon_discount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
couponDiscount: number;
|
||||
|
||||
// Loyalty points
|
||||
@Column({ name: 'loyalty_points_to_use', type: 'int', default: 0 })
|
||||
loyaltyPointsToUse: number;
|
||||
|
||||
@Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
loyaltyPointsValue: number;
|
||||
|
||||
// Shipping
|
||||
@Column({ name: 'shipping_method_id', type: 'uuid', nullable: true })
|
||||
shippingMethodId: string;
|
||||
|
||||
@Column({ name: 'shipping_method_name', length: 100, nullable: true })
|
||||
shippingMethodName: string;
|
||||
|
||||
// Shipping address
|
||||
@Column({ name: 'shipping_address', type: 'jsonb', nullable: true })
|
||||
shippingAddress: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company?: string;
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
instructions?: string;
|
||||
};
|
||||
|
||||
// Billing address
|
||||
@Column({ name: 'billing_address', type: 'jsonb', nullable: true })
|
||||
billingAddress: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company?: string;
|
||||
rfc?: string;
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
@Column({ name: 'billing_same_as_shipping', type: 'boolean', default: true })
|
||||
billingSameAsShipping: boolean;
|
||||
|
||||
// Invoice request
|
||||
@Column({ name: 'requires_invoice', type: 'boolean', default: false })
|
||||
requiresInvoice: boolean;
|
||||
|
||||
@Column({ name: 'invoice_rfc', length: 13, nullable: true })
|
||||
invoiceRfc: string;
|
||||
|
||||
@Column({ name: 'invoice_uso_cfdi', length: 4, nullable: true })
|
||||
invoiceUsoCfdi: string;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Gift
|
||||
@Column({ name: 'is_gift', type: 'boolean', default: false })
|
||||
isGift: boolean;
|
||||
|
||||
@Column({ name: 'gift_message', type: 'text', nullable: true })
|
||||
giftMessage: string;
|
||||
|
||||
// Abandoned cart recovery
|
||||
@Column({ name: 'recovery_email_sent', type: 'boolean', default: false })
|
||||
recoveryEmailSent: boolean;
|
||||
|
||||
@Column({ name: 'recovery_email_sent_at', type: 'timestamp with time zone', nullable: true })
|
||||
recoveryEmailSentAt: Date;
|
||||
|
||||
// Conversion tracking
|
||||
@Column({ name: 'converted_to_order_id', type: 'uuid', nullable: true })
|
||||
convertedToOrderId: string;
|
||||
|
||||
@Column({ name: 'converted_at', type: 'timestamp with time zone', nullable: true })
|
||||
convertedAt: Date;
|
||||
|
||||
// Merge tracking
|
||||
@Column({ name: 'merged_into_cart_id', type: 'uuid', nullable: true })
|
||||
mergedIntoCartId: string;
|
||||
|
||||
// Analytics
|
||||
@Column({ name: 'utm_source', length: 100, nullable: true })
|
||||
utmSource: string;
|
||||
|
||||
@Column({ name: 'utm_medium', length: 100, nullable: true })
|
||||
utmMedium: string;
|
||||
|
||||
@Column({ name: 'utm_campaign', length: 100, nullable: true })
|
||||
utmCampaign: string;
|
||||
|
||||
@Column({ name: 'referrer_url', length: 255, nullable: true })
|
||||
referrerUrl: string;
|
||||
|
||||
// Device info
|
||||
@Column({ name: 'device_type', length: 20, nullable: true })
|
||||
deviceType: 'desktop' | 'tablet' | 'mobile';
|
||||
|
||||
@Column({ name: 'ip_address', length: 45, nullable: true })
|
||||
ipAddress: string;
|
||||
|
||||
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||
userAgent: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@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[];
|
||||
}
|
||||
@ -1,198 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { EcommerceOrder } from './ecommerce-order.entity';
|
||||
|
||||
export enum OrderLineStatus {
|
||||
PENDING = 'pending',
|
||||
RESERVED = 'reserved',
|
||||
FULFILLED = 'fulfilled',
|
||||
SHIPPED = 'shipped',
|
||||
DELIVERED = 'delivered',
|
||||
CANCELLED = 'cancelled',
|
||||
RETURNED = 'returned',
|
||||
REFUNDED = 'refunded',
|
||||
}
|
||||
|
||||
@Entity('ecommerce_order_lines', { schema: 'retail' })
|
||||
@Index(['tenantId', 'orderId'])
|
||||
@Index(['tenantId', 'productId'])
|
||||
export class EcommerceOrderLine {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'order_id', type: 'uuid' })
|
||||
orderId: string;
|
||||
|
||||
@Column({ name: 'line_number', type: 'int' })
|
||||
lineNumber: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: OrderLineStatus,
|
||||
default: OrderLineStatus.PENDING,
|
||||
})
|
||||
status: OrderLineStatus;
|
||||
|
||||
// Product (from erp-core)
|
||||
@Column({ name: 'product_id', type: 'uuid' })
|
||||
productId: string;
|
||||
|
||||
@Column({ name: 'product_code', length: 50 })
|
||||
productCode: string;
|
||||
|
||||
@Column({ name: 'product_name', length: 200 })
|
||||
productName: string;
|
||||
|
||||
@Column({ name: 'product_slug', length: 255, nullable: true })
|
||||
productSlug: string;
|
||||
|
||||
@Column({ name: 'product_image', length: 255, nullable: true })
|
||||
productImage: string;
|
||||
|
||||
// Variant
|
||||
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
|
||||
variantId: string;
|
||||
|
||||
@Column({ name: 'variant_name', length: 200, nullable: true })
|
||||
variantName: string;
|
||||
|
||||
@Column({ name: 'variant_sku', length: 50, nullable: true })
|
||||
variantSku: string;
|
||||
|
||||
@Column({ name: 'variant_options', type: 'jsonb', nullable: true })
|
||||
variantOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
// Quantity
|
||||
@Column({ type: 'decimal', precision: 15, scale: 4, default: 1 })
|
||||
quantity: number;
|
||||
|
||||
@Column({ name: 'quantity_fulfilled', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
quantityFulfilled: number;
|
||||
|
||||
@Column({ name: 'quantity_refunded', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
quantityRefunded: number;
|
||||
|
||||
// UOM
|
||||
@Column({ name: 'uom_id', type: 'uuid', nullable: true })
|
||||
uomId: string;
|
||||
|
||||
@Column({ name: 'uom_name', length: 20, default: 'PZA' })
|
||||
uomName: string;
|
||||
|
||||
// Pricing
|
||||
@Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 })
|
||||
unitPrice: number;
|
||||
|
||||
@Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 })
|
||||
originalPrice: number;
|
||||
|
||||
// Discounts
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'promotion_id', type: 'uuid', nullable: true })
|
||||
promotionId: string;
|
||||
|
||||
@Column({ name: 'promotion_name', length: 100, nullable: true })
|
||||
promotionName: string;
|
||||
|
||||
// Tax
|
||||
@Column({ name: 'tax_id', type: 'uuid', nullable: true })
|
||||
taxId: string;
|
||||
|
||||
@Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 })
|
||||
taxRate: number;
|
||||
|
||||
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
taxAmount: number;
|
||||
|
||||
@Column({ name: 'tax_included', type: 'boolean', default: true })
|
||||
taxIncluded: boolean;
|
||||
|
||||
// Totals
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
subtotal: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
total: number;
|
||||
|
||||
// Cost
|
||||
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||
unitCost: number;
|
||||
|
||||
// Weight (for shipping calculation)
|
||||
@Column({ type: 'decimal', precision: 10, scale: 4, nullable: true })
|
||||
weight: number;
|
||||
|
||||
@Column({ name: 'weight_unit', length: 5, default: 'kg' })
|
||||
weightUnit: string;
|
||||
|
||||
// Warehouse
|
||||
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||
warehouseId: string;
|
||||
|
||||
// Lot/Serial
|
||||
@Column({ name: 'lot_number', length: 50, nullable: true })
|
||||
lotNumber: string;
|
||||
|
||||
@Column({ name: 'serial_number', length: 50, nullable: true })
|
||||
serialNumber: string;
|
||||
|
||||
// Custom options
|
||||
@Column({ name: 'custom_options', type: 'jsonb', nullable: true })
|
||||
customOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
price?: number;
|
||||
}[];
|
||||
|
||||
// Gift
|
||||
@Column({ name: 'is_gift', type: 'boolean', default: false })
|
||||
isGift: boolean;
|
||||
|
||||
@Column({ name: 'gift_message', type: 'text', nullable: true })
|
||||
giftMessage: string;
|
||||
|
||||
// Refund
|
||||
@Column({ name: 'refunded_at', type: 'timestamp with time zone', nullable: true })
|
||||
refundedAt: Date;
|
||||
|
||||
@Column({ name: 'refund_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
refundAmount: number;
|
||||
|
||||
@Column({ name: 'refund_reason', length: 255, nullable: true })
|
||||
refundReason: string;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => EcommerceOrder, (order) => order.lines)
|
||||
@JoinColumn({ name: 'order_id' })
|
||||
order: EcommerceOrder;
|
||||
}
|
||||
@ -1,309 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { EcommerceOrderLine } from './ecommerce-order-line.entity';
|
||||
|
||||
export enum EcommerceOrderStatus {
|
||||
PENDING = 'pending',
|
||||
CONFIRMED = 'confirmed',
|
||||
PROCESSING = 'processing',
|
||||
READY_FOR_PICKUP = 'ready_for_pickup',
|
||||
SHIPPED = 'shipped',
|
||||
DELIVERED = 'delivered',
|
||||
CANCELLED = 'cancelled',
|
||||
REFUNDED = 'refunded',
|
||||
}
|
||||
|
||||
export enum PaymentStatus {
|
||||
PENDING = 'pending',
|
||||
AUTHORIZED = 'authorized',
|
||||
CAPTURED = 'captured',
|
||||
FAILED = 'failed',
|
||||
REFUNDED = 'refunded',
|
||||
PARTIALLY_REFUNDED = 'partially_refunded',
|
||||
}
|
||||
|
||||
export enum FulfillmentType {
|
||||
SHIP = 'ship',
|
||||
PICKUP = 'pickup',
|
||||
DELIVERY = 'delivery',
|
||||
}
|
||||
|
||||
@Entity('ecommerce_orders', { schema: 'retail' })
|
||||
@Index(['tenantId', 'status', 'createdAt'])
|
||||
@Index(['tenantId', 'customerId'])
|
||||
@Index(['tenantId', 'number'], { unique: true })
|
||||
@Index(['tenantId', 'paymentStatus'])
|
||||
export class EcommerceOrder {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 30, unique: true })
|
||||
number: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: EcommerceOrderStatus,
|
||||
default: EcommerceOrderStatus.PENDING,
|
||||
})
|
||||
status: EcommerceOrderStatus;
|
||||
|
||||
@Column({
|
||||
name: 'payment_status',
|
||||
type: 'enum',
|
||||
enum: PaymentStatus,
|
||||
default: PaymentStatus.PENDING,
|
||||
})
|
||||
paymentStatus: PaymentStatus;
|
||||
|
||||
@Column({
|
||||
name: 'fulfillment_type',
|
||||
type: 'enum',
|
||||
enum: FulfillmentType,
|
||||
default: FulfillmentType.SHIP,
|
||||
})
|
||||
fulfillmentType: FulfillmentType;
|
||||
|
||||
// Customer
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId: string;
|
||||
|
||||
@Column({ name: 'customer_email', length: 100 })
|
||||
customerEmail: string;
|
||||
|
||||
@Column({ name: 'customer_phone', length: 20, nullable: true })
|
||||
customerPhone: string;
|
||||
|
||||
@Column({ name: 'customer_name', length: 200 })
|
||||
customerName: string;
|
||||
|
||||
@Column({ name: 'is_guest', type: 'boolean', default: false })
|
||||
isGuest: boolean;
|
||||
|
||||
// Currency
|
||||
@Column({ name: 'currency_code', length: 3, default: 'MXN' })
|
||||
currencyCode: string;
|
||||
|
||||
@Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 })
|
||||
exchangeRate: number;
|
||||
|
||||
// Amounts
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
subtotal: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
taxAmount: number;
|
||||
|
||||
@Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
shippingAmount: number;
|
||||
|
||||
@Column({ name: 'shipping_tax', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
shippingTax: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
total: number;
|
||||
|
||||
@Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
amountPaid: number;
|
||||
|
||||
@Column({ name: 'amount_refunded', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
amountRefunded: number;
|
||||
|
||||
// Item counts
|
||||
@Column({ name: 'items_count', type: 'int', default: 0 })
|
||||
itemsCount: number;
|
||||
|
||||
@Column({ name: 'items_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
itemsQuantity: number;
|
||||
|
||||
// Coupon
|
||||
@Column({ name: 'coupon_id', type: 'uuid', nullable: true })
|
||||
couponId: string;
|
||||
|
||||
@Column({ name: 'coupon_code', length: 50, nullable: true })
|
||||
couponCode: string;
|
||||
|
||||
@Column({ name: 'coupon_discount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
couponDiscount: number;
|
||||
|
||||
// Loyalty
|
||||
@Column({ name: 'loyalty_points_used', type: 'int', default: 0 })
|
||||
loyaltyPointsUsed: number;
|
||||
|
||||
@Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
loyaltyPointsValue: number;
|
||||
|
||||
@Column({ name: 'loyalty_points_earned', type: 'int', default: 0 })
|
||||
loyaltyPointsEarned: number;
|
||||
|
||||
// Shipping address
|
||||
@Column({ name: 'shipping_address', type: 'jsonb' })
|
||||
shippingAddress: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company?: string;
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
instructions?: string;
|
||||
};
|
||||
|
||||
// Billing address
|
||||
@Column({ name: 'billing_address', type: 'jsonb' })
|
||||
billingAddress: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company?: string;
|
||||
rfc?: string;
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
// Shipping
|
||||
@Column({ name: 'shipping_method_id', type: 'uuid', nullable: true })
|
||||
shippingMethodId: string;
|
||||
|
||||
@Column({ name: 'shipping_method_name', length: 100, nullable: true })
|
||||
shippingMethodName: string;
|
||||
|
||||
@Column({ name: 'shipping_carrier', length: 100, nullable: true })
|
||||
shippingCarrier: string;
|
||||
|
||||
@Column({ name: 'tracking_number', length: 100, nullable: true })
|
||||
trackingNumber: string;
|
||||
|
||||
@Column({ name: 'tracking_url', length: 255, nullable: true })
|
||||
trackingUrl: string;
|
||||
|
||||
@Column({ name: 'estimated_delivery', type: 'date', nullable: true })
|
||||
estimatedDelivery: Date;
|
||||
|
||||
@Column({ name: 'shipped_at', type: 'timestamp with time zone', nullable: true })
|
||||
shippedAt: Date;
|
||||
|
||||
@Column({ name: 'delivered_at', type: 'timestamp with time zone', nullable: true })
|
||||
deliveredAt: Date;
|
||||
|
||||
// Pickup
|
||||
@Column({ name: 'pickup_branch_id', type: 'uuid', nullable: true })
|
||||
pickupBranchId: string;
|
||||
|
||||
@Column({ name: 'pickup_ready_at', type: 'timestamp with time zone', nullable: true })
|
||||
pickupReadyAt: Date;
|
||||
|
||||
@Column({ name: 'picked_up_at', type: 'timestamp with time zone', nullable: true })
|
||||
pickedUpAt: Date;
|
||||
|
||||
// Payment
|
||||
@Column({ name: 'payment_method', length: 50, nullable: true })
|
||||
paymentMethod: string;
|
||||
|
||||
@Column({ name: 'payment_gateway', length: 50, nullable: true })
|
||||
paymentGateway: string;
|
||||
|
||||
@Column({ name: 'payment_transaction_id', length: 100, nullable: true })
|
||||
paymentTransactionId: string;
|
||||
|
||||
@Column({ name: 'payment_details', type: 'jsonb', nullable: true })
|
||||
paymentDetails: Record<string, any>;
|
||||
|
||||
// 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<string, any>;
|
||||
|
||||
@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[];
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export * from './cart.entity';
|
||||
export * from './cart-item.entity';
|
||||
export * from './ecommerce-order.entity';
|
||||
export * from './ecommerce-order-line.entity';
|
||||
export * from './shipping-rate.entity';
|
||||
@ -1,182 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum ShippingRateStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
}
|
||||
|
||||
export enum ShippingCalculation {
|
||||
FLAT_RATE = 'flat_rate',
|
||||
BY_WEIGHT = 'by_weight',
|
||||
BY_PRICE = 'by_price',
|
||||
BY_QUANTITY = 'by_quantity',
|
||||
FREE = 'free',
|
||||
CARRIER_CALCULATED = 'carrier_calculated',
|
||||
}
|
||||
|
||||
@Entity('shipping_rates', { schema: 'retail' })
|
||||
@Index(['tenantId', 'status'])
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
export class ShippingRate {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 20, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ShippingRateStatus,
|
||||
default: ShippingRateStatus.ACTIVE,
|
||||
})
|
||||
status: ShippingRateStatus;
|
||||
|
||||
@Column({
|
||||
name: 'calculation_type',
|
||||
type: 'enum',
|
||||
enum: ShippingCalculation,
|
||||
default: ShippingCalculation.FLAT_RATE,
|
||||
})
|
||||
calculationType: ShippingCalculation;
|
||||
|
||||
// Flat rate
|
||||
@Column({ name: 'flat_rate', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
flatRate: number;
|
||||
|
||||
// Weight-based rates
|
||||
@Column({ name: 'weight_rates', type: 'jsonb', nullable: true })
|
||||
weightRates: {
|
||||
minWeight: number;
|
||||
maxWeight: number;
|
||||
rate: number;
|
||||
}[];
|
||||
|
||||
@Column({ name: 'weight_unit', length: 5, default: 'kg' })
|
||||
weightUnit: string;
|
||||
|
||||
// Price-based rates
|
||||
@Column({ name: 'price_rates', type: 'jsonb', nullable: true })
|
||||
priceRates: {
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
rate: number;
|
||||
}[];
|
||||
|
||||
// Quantity-based rates
|
||||
@Column({ name: 'quantity_rates', type: 'jsonb', nullable: true })
|
||||
quantityRates: {
|
||||
minQuantity: number;
|
||||
maxQuantity: number;
|
||||
rate: number;
|
||||
}[];
|
||||
|
||||
// Free shipping threshold
|
||||
@Column({ name: 'free_shipping_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
freeShippingThreshold: number;
|
||||
|
||||
// Carrier integration
|
||||
@Column({ name: 'carrier_code', length: 50, nullable: true })
|
||||
carrierCode: string;
|
||||
|
||||
@Column({ name: 'carrier_name', length: 100, nullable: true })
|
||||
carrierName: string;
|
||||
|
||||
@Column({ name: 'carrier_service', length: 50, nullable: true })
|
||||
carrierService: string;
|
||||
|
||||
@Column({ name: 'carrier_api_config', type: 'jsonb', nullable: true })
|
||||
carrierApiConfig: Record<string, any>;
|
||||
|
||||
// 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<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
}
|
||||
@ -1,401 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { BaseController } from '../../../shared/controllers/base.controller';
|
||||
import { StockTransferService, TransferQueryOptions } from '../services/stock-transfer.service';
|
||||
import { StockAdjustmentService, AdjustmentQueryOptions } from '../services/stock-adjustment.service';
|
||||
|
||||
export class InventoryController extends BaseController {
|
||||
constructor(
|
||||
private readonly transferService: StockTransferService,
|
||||
private readonly adjustmentService: StockAdjustmentService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ==================== TRANSFER ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* Create a stock transfer
|
||||
* POST /inventory/transfers
|
||||
*/
|
||||
createTransfer = async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
};
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export * from './stock-transfer.entity';
|
||||
export * from './stock-transfer-line.entity';
|
||||
export * from './stock-adjustment.entity';
|
||||
export * from './stock-adjustment-line.entity';
|
||||
@ -1,120 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { StockAdjustment } from './stock-adjustment.entity';
|
||||
|
||||
export enum AdjustmentDirection {
|
||||
INCREASE = 'increase',
|
||||
DECREASE = 'decrease',
|
||||
}
|
||||
|
||||
@Entity('stock_adjustment_lines', { schema: 'retail' })
|
||||
@Index(['tenantId', 'adjustmentId'])
|
||||
@Index(['tenantId', 'productId'])
|
||||
export class StockAdjustmentLine {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'adjustment_id', type: 'uuid' })
|
||||
adjustmentId: string;
|
||||
|
||||
@Column({ name: 'line_number', type: 'int' })
|
||||
lineNumber: number;
|
||||
|
||||
// Product (from erp-core)
|
||||
@Column({ name: 'product_id', type: 'uuid' })
|
||||
productId: string;
|
||||
|
||||
@Column({ name: 'product_code', length: 50 })
|
||||
productCode: string;
|
||||
|
||||
@Column({ name: 'product_name', length: 200 })
|
||||
productName: string;
|
||||
|
||||
@Column({ name: 'product_barcode', length: 50, nullable: true })
|
||||
productBarcode: string;
|
||||
|
||||
// Variant
|
||||
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
|
||||
variantId: string;
|
||||
|
||||
@Column({ name: 'variant_name', length: 200, nullable: true })
|
||||
variantName: string;
|
||||
|
||||
// Unit of measure
|
||||
@Column({ name: 'uom_id', type: 'uuid', nullable: true })
|
||||
uomId: string;
|
||||
|
||||
@Column({ name: 'uom_name', length: 20, default: 'PZA' })
|
||||
uomName: string;
|
||||
|
||||
// Stock quantities
|
||||
@Column({ name: 'system_quantity', type: 'decimal', precision: 15, scale: 4 })
|
||||
systemQuantity: number;
|
||||
|
||||
@Column({ name: 'counted_quantity', type: 'decimal', precision: 15, scale: 4 })
|
||||
countedQuantity: number;
|
||||
|
||||
@Column({ name: 'adjustment_quantity', type: 'decimal', precision: 15, scale: 4 })
|
||||
adjustmentQuantity: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: AdjustmentDirection,
|
||||
})
|
||||
direction: AdjustmentDirection;
|
||||
|
||||
// Costs
|
||||
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
unitCost: number;
|
||||
|
||||
@Column({ name: 'adjustment_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
adjustmentValue: number;
|
||||
|
||||
// Location
|
||||
@Column({ name: 'location_id', type: 'uuid', nullable: true })
|
||||
locationId: string;
|
||||
|
||||
@Column({ name: 'location_code', length: 50, nullable: true })
|
||||
locationCode: string;
|
||||
|
||||
// Lot/Serial tracking
|
||||
@Column({ name: 'lot_number', length: 50, nullable: true })
|
||||
lotNumber: string;
|
||||
|
||||
@Column({ name: 'serial_number', length: 50, nullable: true })
|
||||
serialNumber: string;
|
||||
|
||||
@Column({ name: 'expiry_date', type: 'date', nullable: true })
|
||||
expiryDate: Date;
|
||||
|
||||
// Reason (line-level)
|
||||
@Column({ name: 'line_reason', length: 255, nullable: true })
|
||||
lineReason: string;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => StockAdjustment, (adjustment) => adjustment.lines)
|
||||
@JoinColumn({ name: 'adjustment_id' })
|
||||
adjustment: StockAdjustment;
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { StockAdjustmentLine } from './stock-adjustment-line.entity';
|
||||
|
||||
export enum AdjustmentStatus {
|
||||
DRAFT = 'draft',
|
||||
PENDING_APPROVAL = 'pending_approval',
|
||||
APPROVED = 'approved',
|
||||
POSTED = 'posted',
|
||||
REJECTED = 'rejected',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export enum AdjustmentType {
|
||||
INVENTORY_COUNT = 'inventory_count',
|
||||
DAMAGE = 'damage',
|
||||
THEFT = 'theft',
|
||||
EXPIRY = 'expiry',
|
||||
CORRECTION = 'correction',
|
||||
INITIAL_STOCK = 'initial_stock',
|
||||
PRODUCTION = 'production',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
@Entity('stock_adjustments', { schema: 'retail' })
|
||||
@Index(['tenantId', 'branchId', 'status'])
|
||||
@Index(['tenantId', 'adjustmentDate'])
|
||||
@Index(['tenantId', 'number'], { unique: true })
|
||||
export class StockAdjustment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid' })
|
||||
branchId: string;
|
||||
|
||||
@Column({ name: 'warehouse_id', type: 'uuid' })
|
||||
warehouseId: string;
|
||||
|
||||
@Column({ name: 'location_id', type: 'uuid', nullable: true })
|
||||
locationId: string;
|
||||
|
||||
@Column({ length: 30, unique: true })
|
||||
number: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: AdjustmentType,
|
||||
default: AdjustmentType.INVENTORY_COUNT,
|
||||
})
|
||||
type: AdjustmentType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: AdjustmentStatus,
|
||||
default: AdjustmentStatus.DRAFT,
|
||||
})
|
||||
status: AdjustmentStatus;
|
||||
|
||||
@Column({ name: 'adjustment_date', type: 'date' })
|
||||
adjustmentDate: Date;
|
||||
|
||||
// Count info (for inventory counts)
|
||||
@Column({ name: 'is_full_count', type: 'boolean', default: false })
|
||||
isFullCount: boolean;
|
||||
|
||||
@Column({ name: 'count_category_id', type: 'uuid', nullable: true })
|
||||
countCategoryId: string;
|
||||
|
||||
// Summary
|
||||
@Column({ name: 'lines_count', type: 'int', default: 0 })
|
||||
linesCount: number;
|
||||
|
||||
@Column({ name: 'total_increase_qty', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
totalIncreaseQty: number;
|
||||
|
||||
@Column({ name: 'total_decrease_qty', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
totalDecreaseQty: number;
|
||||
|
||||
@Column({ name: 'total_increase_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalIncreaseValue: number;
|
||||
|
||||
@Column({ name: 'total_decrease_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalDecreaseValue: number;
|
||||
|
||||
@Column({ name: 'net_adjustment_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
netAdjustmentValue: number;
|
||||
|
||||
// Financial account (for accounting integration)
|
||||
@Column({ name: 'account_id', type: 'uuid', nullable: true })
|
||||
accountId: string;
|
||||
|
||||
@Column({ name: 'counterpart_account_id', type: 'uuid', nullable: true })
|
||||
counterpartAccountId: string;
|
||||
|
||||
// Approval workflow
|
||||
@Column({ name: 'requires_approval', type: 'boolean', default: true })
|
||||
requiresApproval: boolean;
|
||||
|
||||
@Column({ name: 'approval_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
approvalThreshold: number;
|
||||
|
||||
// Users
|
||||
@Column({ name: 'created_by', type: 'uuid' })
|
||||
createdBy: string;
|
||||
|
||||
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
|
||||
approvedBy: string;
|
||||
|
||||
@Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true })
|
||||
approvedAt: Date;
|
||||
|
||||
@Column({ name: 'posted_by', type: 'uuid', nullable: true })
|
||||
postedBy: string;
|
||||
|
||||
@Column({ name: 'posted_at', type: 'timestamp with time zone', nullable: true })
|
||||
postedAt: Date;
|
||||
|
||||
@Column({ name: 'rejected_by', type: 'uuid', nullable: true })
|
||||
rejectedBy: string;
|
||||
|
||||
@Column({ name: 'rejected_at', type: 'timestamp with time zone', nullable: true })
|
||||
rejectedAt: Date;
|
||||
|
||||
@Column({ name: 'rejection_reason', length: 255, nullable: true })
|
||||
rejectionReason: string;
|
||||
|
||||
// Reason/Description
|
||||
@Column({ type: 'text' })
|
||||
reason: string;
|
||||
|
||||
// Reference
|
||||
@Column({ name: 'reference_type', length: 50, nullable: true })
|
||||
referenceType: string;
|
||||
|
||||
@Column({ name: 'reference_id', type: 'uuid', nullable: true })
|
||||
referenceId: string;
|
||||
|
||||
@Column({ name: 'reference_number', length: 50, nullable: true })
|
||||
referenceNumber: string;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => StockAdjustmentLine, (line) => line.adjustment)
|
||||
lines: StockAdjustmentLine[];
|
||||
}
|
||||
@ -1,144 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { StockTransfer } from './stock-transfer.entity';
|
||||
|
||||
export enum TransferLineStatus {
|
||||
PENDING = 'pending',
|
||||
PARTIALLY_SHIPPED = 'partially_shipped',
|
||||
SHIPPED = 'shipped',
|
||||
PARTIALLY_RECEIVED = 'partially_received',
|
||||
RECEIVED = 'received',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
@Entity('stock_transfer_lines', { schema: 'retail' })
|
||||
@Index(['tenantId', 'transferId'])
|
||||
@Index(['tenantId', 'productId'])
|
||||
export class StockTransferLine {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'transfer_id', type: 'uuid' })
|
||||
transferId: string;
|
||||
|
||||
@Column({ name: 'line_number', type: 'int' })
|
||||
lineNumber: number;
|
||||
|
||||
// Product (from erp-core)
|
||||
@Column({ name: 'product_id', type: 'uuid' })
|
||||
productId: string;
|
||||
|
||||
@Column({ name: 'product_code', length: 50 })
|
||||
productCode: string;
|
||||
|
||||
@Column({ name: 'product_name', length: 200 })
|
||||
productName: string;
|
||||
|
||||
@Column({ name: 'product_barcode', length: 50, nullable: true })
|
||||
productBarcode: string;
|
||||
|
||||
// Variant
|
||||
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
|
||||
variantId: string;
|
||||
|
||||
@Column({ name: 'variant_name', length: 200, nullable: true })
|
||||
variantName: string;
|
||||
|
||||
// Unit of measure
|
||||
@Column({ name: 'uom_id', type: 'uuid', nullable: true })
|
||||
uomId: string;
|
||||
|
||||
@Column({ name: 'uom_name', length: 20, default: 'PZA' })
|
||||
uomName: string;
|
||||
|
||||
// Quantities
|
||||
@Column({ name: 'quantity_requested', type: 'decimal', precision: 15, scale: 4 })
|
||||
quantityRequested: number;
|
||||
|
||||
@Column({ name: 'quantity_approved', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||
quantityApproved: number;
|
||||
|
||||
@Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
quantityShipped: number;
|
||||
|
||||
@Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
quantityReceived: number;
|
||||
|
||||
@Column({ name: 'quantity_difference', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
quantityDifference: number;
|
||||
|
||||
// Costs
|
||||
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
unitCost: number;
|
||||
|
||||
@Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalCost: number;
|
||||
|
||||
// Stock info at time of request
|
||||
@Column({ name: 'source_stock_before', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||
sourceStockBefore: number;
|
||||
|
||||
@Column({ name: 'dest_stock_before', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||
destStockBefore: number;
|
||||
|
||||
// Lot/Serial tracking
|
||||
@Column({ name: 'lot_number', length: 50, nullable: true })
|
||||
lotNumber: string;
|
||||
|
||||
@Column({ name: 'serial_numbers', type: 'jsonb', nullable: true })
|
||||
serialNumbers: string[];
|
||||
|
||||
@Column({ name: 'expiry_date', type: 'date', nullable: true })
|
||||
expiryDate: Date;
|
||||
|
||||
// Status
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TransferLineStatus,
|
||||
default: TransferLineStatus.PENDING,
|
||||
})
|
||||
status: TransferLineStatus;
|
||||
|
||||
// Receiving details
|
||||
@Column({ name: 'received_date', type: 'timestamp with time zone', nullable: true })
|
||||
receivedDate: Date;
|
||||
|
||||
@Column({ name: 'received_by', type: 'uuid', nullable: true })
|
||||
receivedBy: string;
|
||||
|
||||
@Column({ name: 'receiving_notes', length: 255, nullable: true })
|
||||
receivingNotes: string;
|
||||
|
||||
@Column({ name: 'damage_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
damageQuantity: number;
|
||||
|
||||
@Column({ name: 'damage_reason', length: 255, nullable: true })
|
||||
damageReason: string;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => StockTransfer, (transfer) => transfer.lines)
|
||||
@JoinColumn({ name: 'transfer_id' })
|
||||
transfer: StockTransfer;
|
||||
}
|
||||
@ -1,173 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { StockTransferLine } from './stock-transfer-line.entity';
|
||||
|
||||
export enum TransferStatus {
|
||||
DRAFT = 'draft',
|
||||
PENDING_APPROVAL = 'pending_approval',
|
||||
APPROVED = 'approved',
|
||||
IN_TRANSIT = 'in_transit',
|
||||
PARTIALLY_RECEIVED = 'partially_received',
|
||||
RECEIVED = 'received',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export enum TransferType {
|
||||
BRANCH_TO_BRANCH = 'branch_to_branch',
|
||||
WAREHOUSE_TO_BRANCH = 'warehouse_to_branch',
|
||||
BRANCH_TO_WAREHOUSE = 'branch_to_warehouse',
|
||||
INTERNAL = 'internal',
|
||||
}
|
||||
|
||||
@Entity('stock_transfers', { schema: 'retail' })
|
||||
@Index(['tenantId', 'status', 'createdAt'])
|
||||
@Index(['tenantId', 'sourceBranchId'])
|
||||
@Index(['tenantId', 'destBranchId'])
|
||||
@Index(['tenantId', 'number'], { unique: true })
|
||||
export class StockTransfer {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 30, unique: true })
|
||||
number: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TransferType,
|
||||
default: TransferType.BRANCH_TO_BRANCH,
|
||||
})
|
||||
type: TransferType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TransferStatus,
|
||||
default: TransferStatus.DRAFT,
|
||||
})
|
||||
status: TransferStatus;
|
||||
|
||||
// Source
|
||||
@Column({ name: 'source_branch_id', type: 'uuid', nullable: true })
|
||||
sourceBranchId: string;
|
||||
|
||||
@Column({ name: 'source_warehouse_id', type: 'uuid' })
|
||||
sourceWarehouseId: string;
|
||||
|
||||
@Column({ name: 'source_location_id', type: 'uuid', nullable: true })
|
||||
sourceLocationId: string;
|
||||
|
||||
// Destination
|
||||
@Column({ name: 'dest_branch_id', type: 'uuid', nullable: true })
|
||||
destBranchId: string;
|
||||
|
||||
@Column({ name: 'dest_warehouse_id', type: 'uuid' })
|
||||
destWarehouseId: string;
|
||||
|
||||
@Column({ name: 'dest_location_id', type: 'uuid', nullable: true })
|
||||
destLocationId: string;
|
||||
|
||||
// Dates
|
||||
@Column({ name: 'requested_date', type: 'date' })
|
||||
requestedDate: Date;
|
||||
|
||||
@Column({ name: 'expected_date', type: 'date', nullable: true })
|
||||
expectedDate: Date;
|
||||
|
||||
@Column({ name: 'shipped_date', type: 'timestamp with time zone', nullable: true })
|
||||
shippedDate: Date;
|
||||
|
||||
@Column({ name: 'received_date', type: 'timestamp with time zone', nullable: true })
|
||||
receivedDate: Date;
|
||||
|
||||
// Line counts
|
||||
@Column({ name: 'lines_count', type: 'int', default: 0 })
|
||||
linesCount: number;
|
||||
|
||||
@Column({ name: 'items_requested', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
itemsRequested: number;
|
||||
|
||||
@Column({ name: 'items_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
itemsShipped: number;
|
||||
|
||||
@Column({ name: 'items_received', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||
itemsReceived: number;
|
||||
|
||||
// Totals (for reporting)
|
||||
@Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalCost: number;
|
||||
|
||||
@Column({ name: 'total_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalValue: number;
|
||||
|
||||
// Shipping info
|
||||
@Column({ name: 'shipping_method', length: 50, nullable: true })
|
||||
shippingMethod: string;
|
||||
|
||||
@Column({ name: 'tracking_number', length: 100, nullable: true })
|
||||
trackingNumber: string;
|
||||
|
||||
@Column({ name: 'carrier_name', length: 100, nullable: true })
|
||||
carrierName: string;
|
||||
|
||||
// Users
|
||||
@Column({ name: 'requested_by', type: 'uuid' })
|
||||
requestedBy: string;
|
||||
|
||||
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
|
||||
approvedBy: string;
|
||||
|
||||
@Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true })
|
||||
approvedAt: Date;
|
||||
|
||||
@Column({ name: 'shipped_by', type: 'uuid', nullable: true })
|
||||
shippedBy: string;
|
||||
|
||||
@Column({ name: 'received_by', type: 'uuid', nullable: true })
|
||||
receivedBy: string;
|
||||
|
||||
// Priority
|
||||
@Column({ type: 'int', default: 0 })
|
||||
priority: number; // 0=normal, 1=high, 2=urgent
|
||||
|
||||
// Reason
|
||||
@Column({ type: 'text', nullable: true })
|
||||
reason: string;
|
||||
|
||||
// Reference (for transfers generated from sales, etc.)
|
||||
@Column({ name: 'reference_type', length: 50, nullable: true })
|
||||
referenceType: string;
|
||||
|
||||
@Column({ name: 'reference_id', type: 'uuid', nullable: true })
|
||||
referenceId: string;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@Column({ name: 'receiving_notes', type: 'text', nullable: true })
|
||||
receivingNotes: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => StockTransferLine, (line) => line.transfer)
|
||||
lines: StockTransferLine[];
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
export * from './controllers/inventory.controller';
|
||||
export * from './routes/inventory.routes';
|
||||
export * from './validation/inventory.schema';
|
||||
@ -1,179 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { InventoryController } from '../controllers/inventory.controller';
|
||||
import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware';
|
||||
import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware';
|
||||
import {
|
||||
createTransferSchema,
|
||||
shipTransferSchema,
|
||||
receiveTransferSchema,
|
||||
cancelTransferSchema,
|
||||
listTransfersQuerySchema,
|
||||
createAdjustmentSchema,
|
||||
addAdjustmentLineSchema,
|
||||
rejectAdjustmentSchema,
|
||||
cancelAdjustmentSchema,
|
||||
listAdjustmentsQuerySchema,
|
||||
} from '../validation/inventory.schema';
|
||||
|
||||
export function createInventoryRoutes(controller: InventoryController): Router {
|
||||
const router = Router();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
router.use(requireAuth);
|
||||
|
||||
// ==================== TRANSFER ROUTES ====================
|
||||
|
||||
// Create transfer
|
||||
router.post(
|
||||
'/transfers',
|
||||
requirePermissions(['inventory.transfers.create']),
|
||||
validateBody(createTransferSchema),
|
||||
controller.createTransfer
|
||||
);
|
||||
|
||||
// List transfers
|
||||
router.get(
|
||||
'/transfers',
|
||||
requirePermissions(['inventory.transfers.view']),
|
||||
validateQuery(listTransfersQuerySchema),
|
||||
controller.listTransfers
|
||||
);
|
||||
|
||||
// Get transfer summary
|
||||
router.get(
|
||||
'/transfers/summary',
|
||||
requirePermissions(['inventory.transfers.view']),
|
||||
controller.getTransferSummary
|
||||
);
|
||||
|
||||
// Get incoming transfers
|
||||
router.get(
|
||||
'/transfers/incoming',
|
||||
requirePermissions(['inventory.transfers.view']),
|
||||
controller.getIncomingTransfers
|
||||
);
|
||||
|
||||
// Get transfer by ID
|
||||
router.get(
|
||||
'/transfers/:id',
|
||||
requirePermissions(['inventory.transfers.view']),
|
||||
controller.getTransfer
|
||||
);
|
||||
|
||||
// Submit transfer for approval
|
||||
router.post(
|
||||
'/transfers/:id/submit',
|
||||
requirePermissions(['inventory.transfers.submit']),
|
||||
controller.submitTransfer
|
||||
);
|
||||
|
||||
// Approve transfer
|
||||
router.post(
|
||||
'/transfers/:id/approve',
|
||||
requireRoles(['admin', 'warehouse_manager', 'manager']),
|
||||
controller.approveTransfer
|
||||
);
|
||||
|
||||
// Ship transfer
|
||||
router.post(
|
||||
'/transfers/:id/ship',
|
||||
requirePermissions(['inventory.transfers.ship']),
|
||||
validateBody(shipTransferSchema),
|
||||
controller.shipTransfer
|
||||
);
|
||||
|
||||
// Receive transfer
|
||||
router.post(
|
||||
'/transfers/:id/receive',
|
||||
requirePermissions(['inventory.transfers.receive']),
|
||||
validateBody(receiveTransferSchema),
|
||||
controller.receiveTransfer
|
||||
);
|
||||
|
||||
// Cancel transfer
|
||||
router.post(
|
||||
'/transfers/:id/cancel',
|
||||
requirePermissions(['inventory.transfers.cancel']),
|
||||
validateBody(cancelTransferSchema),
|
||||
controller.cancelTransfer
|
||||
);
|
||||
|
||||
// ==================== ADJUSTMENT ROUTES ====================
|
||||
|
||||
// Create adjustment
|
||||
router.post(
|
||||
'/adjustments',
|
||||
requirePermissions(['inventory.adjustments.create']),
|
||||
validateBody(createAdjustmentSchema),
|
||||
controller.createAdjustment
|
||||
);
|
||||
|
||||
// List adjustments
|
||||
router.get(
|
||||
'/adjustments',
|
||||
requirePermissions(['inventory.adjustments.view']),
|
||||
validateQuery(listAdjustmentsQuerySchema),
|
||||
controller.listAdjustments
|
||||
);
|
||||
|
||||
// Get adjustment summary
|
||||
router.get(
|
||||
'/adjustments/summary',
|
||||
requirePermissions(['inventory.adjustments.view']),
|
||||
controller.getAdjustmentSummary
|
||||
);
|
||||
|
||||
// Get adjustment by ID
|
||||
router.get(
|
||||
'/adjustments/:id',
|
||||
requirePermissions(['inventory.adjustments.view']),
|
||||
controller.getAdjustment
|
||||
);
|
||||
|
||||
// Add line to adjustment
|
||||
router.post(
|
||||
'/adjustments/:id/lines',
|
||||
requirePermissions(['inventory.adjustments.edit']),
|
||||
validateBody(addAdjustmentLineSchema),
|
||||
controller.addAdjustmentLine
|
||||
);
|
||||
|
||||
// Submit adjustment for approval
|
||||
router.post(
|
||||
'/adjustments/:id/submit',
|
||||
requirePermissions(['inventory.adjustments.submit']),
|
||||
controller.submitAdjustment
|
||||
);
|
||||
|
||||
// Approve adjustment
|
||||
router.post(
|
||||
'/adjustments/:id/approve',
|
||||
requireRoles(['admin', 'warehouse_manager', 'manager']),
|
||||
controller.approveAdjustment
|
||||
);
|
||||
|
||||
// Reject adjustment
|
||||
router.post(
|
||||
'/adjustments/:id/reject',
|
||||
requireRoles(['admin', 'warehouse_manager', 'manager']),
|
||||
validateBody(rejectAdjustmentSchema),
|
||||
controller.rejectAdjustment
|
||||
);
|
||||
|
||||
// Post adjustment
|
||||
router.post(
|
||||
'/adjustments/:id/post',
|
||||
requireRoles(['admin', 'warehouse_manager']),
|
||||
controller.postAdjustment
|
||||
);
|
||||
|
||||
// Cancel adjustment
|
||||
router.post(
|
||||
'/adjustments/:id/cancel',
|
||||
requirePermissions(['inventory.adjustments.cancel']),
|
||||
validateBody(cancelAdjustmentSchema),
|
||||
controller.cancelAdjustment
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from './stock-transfer.service';
|
||||
export * from './stock-adjustment.service';
|
||||
@ -1,630 +0,0 @@
|
||||
import { Repository, DataSource, Between } from 'typeorm';
|
||||
import { BaseService } from '../../../shared/services/base.service';
|
||||
import { ServiceResult, QueryOptions } from '../../../shared/types';
|
||||
import {
|
||||
StockAdjustment,
|
||||
AdjustmentStatus,
|
||||
AdjustmentType,
|
||||
} from '../entities/stock-adjustment.entity';
|
||||
import { StockAdjustmentLine, AdjustmentDirection } from '../entities/stock-adjustment-line.entity';
|
||||
|
||||
export interface AdjustmentLineInput {
|
||||
productId: string;
|
||||
productCode: string;
|
||||
productName: string;
|
||||
productBarcode?: string;
|
||||
variantId?: string;
|
||||
variantName?: string;
|
||||
uomId?: string;
|
||||
uomName?: string;
|
||||
systemQuantity: number;
|
||||
countedQuantity: number;
|
||||
unitCost?: number;
|
||||
locationId?: string;
|
||||
locationCode?: string;
|
||||
lotNumber?: string;
|
||||
serialNumber?: string;
|
||||
expiryDate?: Date;
|
||||
lineReason?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateAdjustmentInput {
|
||||
branchId: string;
|
||||
warehouseId: string;
|
||||
locationId?: string;
|
||||
type: AdjustmentType;
|
||||
adjustmentDate: Date;
|
||||
isFullCount?: boolean;
|
||||
countCategoryId?: string;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
lines: AdjustmentLineInput[];
|
||||
}
|
||||
|
||||
export interface AdjustmentQueryOptions extends QueryOptions {
|
||||
branchId?: string;
|
||||
warehouseId?: string;
|
||||
status?: AdjustmentStatus;
|
||||
type?: AdjustmentType;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}
|
||||
|
||||
export class StockAdjustmentService extends BaseService<StockAdjustment> {
|
||||
constructor(
|
||||
repository: Repository<StockAdjustment>,
|
||||
private readonly lineRepository: Repository<StockAdjustmentLine>,
|
||||
private readonly dataSource: DataSource
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate adjustment number
|
||||
*/
|
||||
private async generateAdjustmentNumber(tenantId: string, type: AdjustmentType): Promise<string> {
|
||||
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<ServiceResult<StockAdjustment>> {
|
||||
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<ServiceResult<StockAdjustment>> {
|
||||
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<ServiceResult<StockAdjustment>> {
|
||||
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<ServiceResult<StockAdjustment>> {
|
||||
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<ServiceResult<StockAdjustment>> {
|
||||
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<ServiceResult<StockAdjustment>> {
|
||||
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<StockAdjustment | null> {
|
||||
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<AdjustmentType, { count: number; value: number }>;
|
||||
byStatus: Record<AdjustmentStatus, number>;
|
||||
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<string, { count: number; value: number }> = {};
|
||||
const byStatus: Record<string, number> = {};
|
||||
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<AdjustmentType, { count: number; value: number }>,
|
||||
byStatus: byStatus as Record<AdjustmentStatus, number>,
|
||||
totalIncreaseValue,
|
||||
totalDecreaseValue,
|
||||
netValue: totalIncreaseValue - totalDecreaseValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add line to existing adjustment
|
||||
*/
|
||||
async addLine(
|
||||
tenantId: string,
|
||||
adjustmentId: string,
|
||||
lineInput: AdjustmentLineInput,
|
||||
userId: string
|
||||
): Promise<ServiceResult<StockAdjustmentLine>> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,591 +0,0 @@
|
||||
import { Repository, DataSource, Between, In } from 'typeorm';
|
||||
import { BaseService } from '../../../shared/services/base.service';
|
||||
import { ServiceResult, QueryOptions } from '../../../shared/types';
|
||||
import {
|
||||
StockTransfer,
|
||||
TransferStatus,
|
||||
TransferType,
|
||||
} from '../entities/stock-transfer.entity';
|
||||
import { StockTransferLine, TransferLineStatus } from '../entities/stock-transfer-line.entity';
|
||||
|
||||
export interface TransferLineInput {
|
||||
productId: string;
|
||||
productCode: string;
|
||||
productName: string;
|
||||
productBarcode?: string;
|
||||
variantId?: string;
|
||||
variantName?: string;
|
||||
uomId?: string;
|
||||
uomName?: string;
|
||||
quantityRequested: number;
|
||||
unitCost?: number;
|
||||
lotNumber?: string;
|
||||
serialNumbers?: string[];
|
||||
expiryDate?: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateTransferInput {
|
||||
type: TransferType;
|
||||
sourceBranchId?: string;
|
||||
sourceWarehouseId: string;
|
||||
sourceLocationId?: string;
|
||||
destBranchId?: string;
|
||||
destWarehouseId: string;
|
||||
destLocationId?: string;
|
||||
requestedDate: Date;
|
||||
expectedDate?: Date;
|
||||
priority?: number;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
lines: TransferLineInput[];
|
||||
}
|
||||
|
||||
export interface TransferQueryOptions extends QueryOptions {
|
||||
branchId?: string;
|
||||
sourceBranchId?: string;
|
||||
destBranchId?: string;
|
||||
status?: TransferStatus;
|
||||
type?: TransferType;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}
|
||||
|
||||
export interface ShipLineInput {
|
||||
lineId: string;
|
||||
quantityShipped: number;
|
||||
lotNumber?: string;
|
||||
serialNumbers?: string[];
|
||||
}
|
||||
|
||||
export interface ReceiveLineInput {
|
||||
lineId: string;
|
||||
quantityReceived: number;
|
||||
damageQuantity?: number;
|
||||
damageReason?: string;
|
||||
receivingNotes?: string;
|
||||
}
|
||||
|
||||
export class StockTransferService extends BaseService<StockTransfer> {
|
||||
constructor(
|
||||
repository: Repository<StockTransfer>,
|
||||
private readonly lineRepository: Repository<StockTransferLine>,
|
||||
private readonly dataSource: DataSource
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate transfer number
|
||||
*/
|
||||
private async generateTransferNumber(tenantId: string): Promise<string> {
|
||||
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<ServiceResult<StockTransfer>> {
|
||||
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<ServiceResult<StockTransfer>> {
|
||||
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<ServiceResult<StockTransfer>> {
|
||||
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<ServiceResult<StockTransfer>> {
|
||||
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<ServiceResult<StockTransfer>> {
|
||||
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<ServiceResult<StockTransfer>> {
|
||||
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<StockTransfer | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['lines'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending transfers for a branch (incoming)
|
||||
*/
|
||||
async getPendingIncomingTransfers(
|
||||
tenantId: string,
|
||||
branchId: string
|
||||
): Promise<StockTransfer[]> {
|
||||
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<Record<TransferStatus, number>> {
|
||||
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<string, number> = {};
|
||||
|
||||
for (const r of results) {
|
||||
summary[r.status] = parseInt(r.count, 10);
|
||||
}
|
||||
|
||||
return summary as Record<TransferStatus, number>;
|
||||
}
|
||||
}
|
||||
@ -1,200 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
uuidSchema,
|
||||
moneySchema,
|
||||
quantitySchema,
|
||||
paginationSchema,
|
||||
} from '../../../shared/validation/common.schema';
|
||||
|
||||
// Enums
|
||||
export const transferTypeEnum = z.enum([
|
||||
'branch_to_branch',
|
||||
'warehouse_to_branch',
|
||||
'branch_to_warehouse',
|
||||
'internal',
|
||||
]);
|
||||
|
||||
export const transferStatusEnum = z.enum([
|
||||
'draft',
|
||||
'pending_approval',
|
||||
'approved',
|
||||
'in_transit',
|
||||
'partially_received',
|
||||
'received',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
export const adjustmentTypeEnum = z.enum([
|
||||
'inventory_count',
|
||||
'damage',
|
||||
'theft',
|
||||
'expiry',
|
||||
'correction',
|
||||
'initial_stock',
|
||||
'production',
|
||||
'other',
|
||||
]);
|
||||
|
||||
export const adjustmentStatusEnum = z.enum([
|
||||
'draft',
|
||||
'pending_approval',
|
||||
'approved',
|
||||
'posted',
|
||||
'rejected',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
// ==================== TRANSFER SCHEMAS ====================
|
||||
|
||||
// Transfer line schema
|
||||
export const transferLineSchema = z.object({
|
||||
productId: uuidSchema,
|
||||
productCode: z.string().min(1).max(50),
|
||||
productName: z.string().min(1).max(200),
|
||||
productBarcode: z.string().max(50).optional(),
|
||||
variantId: uuidSchema.optional(),
|
||||
variantName: z.string().max(200).optional(),
|
||||
uomId: uuidSchema.optional(),
|
||||
uomName: z.string().max(20).optional(),
|
||||
quantityRequested: quantitySchema.positive('Quantity must be positive'),
|
||||
unitCost: moneySchema.optional(),
|
||||
lotNumber: z.string().max(50).optional(),
|
||||
serialNumbers: z.array(z.string().max(50)).optional(),
|
||||
expiryDate: z.coerce.date().optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
// Create transfer schema
|
||||
export const createTransferSchema = z.object({
|
||||
type: transferTypeEnum,
|
||||
sourceBranchId: uuidSchema.optional(),
|
||||
sourceWarehouseId: uuidSchema,
|
||||
sourceLocationId: uuidSchema.optional(),
|
||||
destBranchId: uuidSchema.optional(),
|
||||
destWarehouseId: uuidSchema,
|
||||
destLocationId: uuidSchema.optional(),
|
||||
requestedDate: z.coerce.date(),
|
||||
expectedDate: z.coerce.date().optional(),
|
||||
priority: z.number().int().min(0).max(2).optional(),
|
||||
reason: z.string().max(500).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
lines: z.array(transferLineSchema).min(1, 'At least one line is required'),
|
||||
});
|
||||
|
||||
// Ship line schema
|
||||
export const shipLineSchema = z.object({
|
||||
lineId: uuidSchema,
|
||||
quantityShipped: quantitySchema.positive('Quantity must be positive'),
|
||||
lotNumber: z.string().max(50).optional(),
|
||||
serialNumbers: z.array(z.string().max(50)).optional(),
|
||||
});
|
||||
|
||||
// Ship transfer schema
|
||||
export const shipTransferSchema = z.object({
|
||||
lines: z.array(shipLineSchema).min(1, 'At least one line is required'),
|
||||
shippingMethod: z.string().max(50).optional(),
|
||||
trackingNumber: z.string().max(100).optional(),
|
||||
carrierName: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
// Receive line schema
|
||||
export const receiveLineSchema = z.object({
|
||||
lineId: uuidSchema,
|
||||
quantityReceived: quantitySchema.min(0),
|
||||
damageQuantity: quantitySchema.optional(),
|
||||
damageReason: z.string().max(255).optional(),
|
||||
receivingNotes: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
// Receive transfer schema
|
||||
export const receiveTransferSchema = z.object({
|
||||
lines: z.array(receiveLineSchema).min(1, 'At least one line is required'),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
// Cancel transfer schema
|
||||
export const cancelTransferSchema = z.object({
|
||||
reason: z.string().min(1, 'Reason is required').max(255),
|
||||
});
|
||||
|
||||
// List transfers query schema
|
||||
export const listTransfersQuerySchema = paginationSchema.extend({
|
||||
branchId: uuidSchema.optional(),
|
||||
sourceBranchId: uuidSchema.optional(),
|
||||
destBranchId: uuidSchema.optional(),
|
||||
status: transferStatusEnum.optional(),
|
||||
type: transferTypeEnum.optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
// ==================== ADJUSTMENT SCHEMAS ====================
|
||||
|
||||
// Adjustment line schema
|
||||
export const adjustmentLineSchema = z.object({
|
||||
productId: uuidSchema,
|
||||
productCode: z.string().min(1).max(50),
|
||||
productName: z.string().min(1).max(200),
|
||||
productBarcode: z.string().max(50).optional(),
|
||||
variantId: uuidSchema.optional(),
|
||||
variantName: z.string().max(200).optional(),
|
||||
uomId: uuidSchema.optional(),
|
||||
uomName: z.string().max(20).optional(),
|
||||
systemQuantity: quantitySchema,
|
||||
countedQuantity: quantitySchema,
|
||||
unitCost: moneySchema.optional(),
|
||||
locationId: uuidSchema.optional(),
|
||||
locationCode: z.string().max(50).optional(),
|
||||
lotNumber: z.string().max(50).optional(),
|
||||
serialNumber: z.string().max(50).optional(),
|
||||
expiryDate: z.coerce.date().optional(),
|
||||
lineReason: z.string().max(255).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
// Create adjustment schema
|
||||
export const createAdjustmentSchema = z.object({
|
||||
branchId: uuidSchema,
|
||||
warehouseId: uuidSchema,
|
||||
locationId: uuidSchema.optional(),
|
||||
type: adjustmentTypeEnum,
|
||||
adjustmentDate: z.coerce.date(),
|
||||
isFullCount: z.boolean().optional(),
|
||||
countCategoryId: uuidSchema.optional(),
|
||||
reason: z.string().min(1, 'Reason is required').max(500),
|
||||
notes: z.string().max(1000).optional(),
|
||||
lines: z.array(adjustmentLineSchema).min(1, 'At least one line is required'),
|
||||
});
|
||||
|
||||
// Add adjustment line schema
|
||||
export const addAdjustmentLineSchema = adjustmentLineSchema;
|
||||
|
||||
// Reject adjustment schema
|
||||
export const rejectAdjustmentSchema = z.object({
|
||||
reason: z.string().min(1, 'Rejection reason is required').max(255),
|
||||
});
|
||||
|
||||
// Cancel adjustment schema
|
||||
export const cancelAdjustmentSchema = z.object({
|
||||
reason: z.string().min(1, 'Cancellation reason is required').max(255),
|
||||
});
|
||||
|
||||
// List adjustments query schema
|
||||
export const listAdjustmentsQuerySchema = paginationSchema.extend({
|
||||
branchId: uuidSchema.optional(),
|
||||
warehouseId: uuidSchema.optional(),
|
||||
status: adjustmentStatusEnum.optional(),
|
||||
type: adjustmentTypeEnum.optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
// Types
|
||||
export type CreateTransferInput = z.infer<typeof createTransferSchema>;
|
||||
export type TransferLineInput = z.infer<typeof transferLineSchema>;
|
||||
export type ShipTransferInput = z.infer<typeof shipTransferSchema>;
|
||||
export type ReceiveTransferInput = z.infer<typeof receiveTransferSchema>;
|
||||
export type ListTransfersQuery = z.infer<typeof listTransfersQuerySchema>;
|
||||
export type CreateAdjustmentInput = z.infer<typeof createAdjustmentSchema>;
|
||||
export type AdjustmentLineInput = z.infer<typeof adjustmentLineSchema>;
|
||||
export type ListAdjustmentsQuery = z.infer<typeof listAdjustmentsQuerySchema>;
|
||||
@ -1,742 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { CFDIService } from '../services/cfdi.service';
|
||||
import { CFDIBuilderService } from '../services/cfdi-builder.service';
|
||||
import { AppDataSource } from '../../../config/database';
|
||||
import { CFDI } from '../entities/cfdi.entity';
|
||||
import { CFDIConfig } from '../entities/cfdi-config.entity';
|
||||
import { CANCELLATION_REASONS } from '../services/pac.service';
|
||||
import {
|
||||
CreateCFDIInput,
|
||||
CancelCFDIInput,
|
||||
ListCFDIsQuery,
|
||||
ResendEmailInput,
|
||||
CreateCreditNoteInput,
|
||||
CreateCFDIConfigInput,
|
||||
UpdateCFDIConfigInput,
|
||||
AutofacturaRequest,
|
||||
LookupOrderInput,
|
||||
CFDIStatsQuery,
|
||||
} from '../validation/cfdi.schema';
|
||||
|
||||
const cfdiService = new CFDIService(
|
||||
AppDataSource,
|
||||
AppDataSource.getRepository(CFDI)
|
||||
);
|
||||
const builderService = new CFDIBuilderService();
|
||||
const configRepository = AppDataSource.getRepository(CFDIConfig);
|
||||
|
||||
// ==================== CFDI ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* Create a new CFDI (invoice)
|
||||
* POST /api/invoicing/cfdis
|
||||
*/
|
||||
export const createCFDI = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<CreateCreditNoteInput, 'originalCfdiId'>;
|
||||
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export * from './cfdi.controller';
|
||||
@ -1,199 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum CFDIConfigStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
PENDING_VALIDATION = 'pending_validation',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
export enum PACProvider {
|
||||
FINKOK = 'finkok',
|
||||
SOLUCIONES_FISCALES = 'soluciones_fiscales',
|
||||
FACTURAPI = 'facturapi',
|
||||
SW_SAPIEN = 'sw_sapien',
|
||||
DIGISAT = 'digisat',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
@Entity('cfdi_configs', { schema: 'retail' })
|
||||
@Index(['tenantId', 'branchId'])
|
||||
@Index(['tenantId', 'rfc'], { unique: true })
|
||||
@Index(['tenantId', 'status'])
|
||||
export class CFDIConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
|
||||
branchId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CFDIConfigStatus,
|
||||
default: CFDIConfigStatus.PENDING_VALIDATION,
|
||||
})
|
||||
status: CFDIConfigStatus;
|
||||
|
||||
// Emisor (Issuer) Info
|
||||
@Column({ length: 13 })
|
||||
rfc: string;
|
||||
|
||||
@Column({ name: 'razon_social', length: 300 })
|
||||
razonSocial: string;
|
||||
|
||||
@Column({ name: 'nombre_comercial', length: 200, nullable: true })
|
||||
nombreComercial: string;
|
||||
|
||||
@Column({ name: 'regimen_fiscal', length: 3 })
|
||||
regimenFiscal: string; // SAT catalog code
|
||||
|
||||
@Column({ name: 'regimen_fiscal_descripcion', length: 200, nullable: true })
|
||||
regimenFiscalDescripcion: string;
|
||||
|
||||
// Address
|
||||
@Column({ name: 'codigo_postal', length: 5 })
|
||||
codigoPostal: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
domicilio: string;
|
||||
|
||||
// PAC Configuration
|
||||
@Column({
|
||||
name: 'pac_provider',
|
||||
type: 'enum',
|
||||
enum: PACProvider,
|
||||
default: PACProvider.FINKOK,
|
||||
})
|
||||
pacProvider: PACProvider;
|
||||
|
||||
@Column({ name: 'pac_username', length: 100, nullable: true })
|
||||
pacUsername: string;
|
||||
|
||||
@Column({ name: 'pac_password_encrypted', type: 'text', nullable: true })
|
||||
pacPasswordEncrypted: string;
|
||||
|
||||
@Column({ name: 'pac_environment', length: 20, default: 'sandbox' })
|
||||
pacEnvironment: 'sandbox' | 'production';
|
||||
|
||||
@Column({ name: 'pac_api_url', length: 255, nullable: true })
|
||||
pacApiUrl: string;
|
||||
|
||||
// Certificate (CSD)
|
||||
@Column({ name: 'certificate_number', length: 20, nullable: true })
|
||||
certificateNumber: string;
|
||||
|
||||
@Column({ name: 'certificate_encrypted', type: 'text', nullable: true })
|
||||
certificateEncrypted: string;
|
||||
|
||||
@Column({ name: 'private_key_encrypted', type: 'text', nullable: true })
|
||||
privateKeyEncrypted: string;
|
||||
|
||||
@Column({ name: 'certificate_password_encrypted', type: 'text', nullable: true })
|
||||
certificatePasswordEncrypted: string;
|
||||
|
||||
@Column({ name: 'certificate_valid_from', type: 'timestamp with time zone', nullable: true })
|
||||
certificateValidFrom: Date;
|
||||
|
||||
@Column({ name: 'certificate_valid_until', type: 'timestamp with time zone', nullable: true })
|
||||
certificateValidUntil: Date;
|
||||
|
||||
// CFDI defaults
|
||||
@Column({ name: 'default_serie', length: 10, default: 'A' })
|
||||
defaultSerie: string;
|
||||
|
||||
@Column({ name: 'current_folio', type: 'int', default: 1 })
|
||||
currentFolio: number;
|
||||
|
||||
@Column({ name: 'default_forma_pago', length: 2, default: '01' })
|
||||
defaultFormaPago: string; // 01=Efectivo, 03=Transferencia, etc.
|
||||
|
||||
@Column({ name: 'default_metodo_pago', length: 3, default: 'PUE' })
|
||||
defaultMetodoPago: string; // PUE=Pago en Una Exhibición, PPD=Pago en Parcialidades
|
||||
|
||||
@Column({ name: 'default_uso_cfdi', length: 4, default: 'G03' })
|
||||
defaultUsoCFDI: string; // G03=Gastos en general
|
||||
|
||||
@Column({ name: 'default_moneda', length: 3, default: 'MXN' })
|
||||
defaultMoneda: string;
|
||||
|
||||
// Logo and branding
|
||||
@Column({ name: 'logo_url', length: 255, nullable: true })
|
||||
logoUrl: string;
|
||||
|
||||
@Column({ name: 'pdf_template', length: 50, default: 'standard' })
|
||||
pdfTemplate: string;
|
||||
|
||||
@Column({ name: 'footer_text', type: 'text', nullable: true })
|
||||
footerText: string;
|
||||
|
||||
// Email settings
|
||||
@Column({ name: 'send_email_on_issue', type: 'boolean', default: true })
|
||||
sendEmailOnIssue: boolean;
|
||||
|
||||
@Column({ name: 'email_from', length: 100, nullable: true })
|
||||
emailFrom: string;
|
||||
|
||||
@Column({ name: 'email_subject_template', length: 200, nullable: true })
|
||||
emailSubjectTemplate: string;
|
||||
|
||||
@Column({ name: 'email_body_template', type: 'text', nullable: true })
|
||||
emailBodyTemplate: string;
|
||||
|
||||
// Notification settings
|
||||
@Column({ name: 'notify_certificate_expiry', type: 'boolean', default: true })
|
||||
notifyCertificateExpiry: boolean;
|
||||
|
||||
@Column({ name: 'notify_days_before_expiry', type: 'int', default: 30 })
|
||||
notifyDaysBeforeExpiry: number;
|
||||
|
||||
// Auto-invoicing
|
||||
@Column({ name: 'auto_invoice_pos', type: 'boolean', default: false })
|
||||
autoInvoicePOS: boolean;
|
||||
|
||||
@Column({ name: 'auto_invoice_ecommerce', type: 'boolean', default: true })
|
||||
autoInvoiceEcommerce: boolean;
|
||||
|
||||
// Rate limiting
|
||||
@Column({ name: 'max_invoices_per_day', type: 'int', nullable: true })
|
||||
maxInvoicesPerDay: number;
|
||||
|
||||
@Column({ name: 'invoices_issued_today', type: 'int', default: 0 })
|
||||
invoicesIssuedToday: number;
|
||||
|
||||
@Column({ name: 'last_invoice_reset', type: 'date', nullable: true })
|
||||
lastInvoiceReset: Date;
|
||||
|
||||
// Validation
|
||||
@Column({ name: 'last_validated_at', type: 'timestamp with time zone', nullable: true })
|
||||
lastValidatedAt: Date;
|
||||
|
||||
@Column({ name: 'validation_errors', type: 'jsonb', nullable: true })
|
||||
validationErrors: string[];
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@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;
|
||||
}
|
||||
@ -1,294 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum CFDIStatus {
|
||||
DRAFT = 'draft',
|
||||
PENDING = 'pending',
|
||||
STAMPED = 'stamped',
|
||||
CANCELLED = 'cancelled',
|
||||
CANCELLATION_PENDING = 'cancellation_pending',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export enum CFDIType {
|
||||
INGRESO = 'I', // Income (sale invoice)
|
||||
EGRESO = 'E', // Expense (credit note)
|
||||
TRASLADO = 'T', // Transfer
|
||||
NOMINA = 'N', // Payroll
|
||||
PAGO = 'P', // Payment receipt
|
||||
}
|
||||
|
||||
@Entity('cfdis', { schema: 'retail' })
|
||||
@Index(['tenantId', 'status', 'fechaEmision'])
|
||||
@Index(['tenantId', 'uuid'], { unique: true })
|
||||
@Index(['tenantId', 'serie', 'folio'], { unique: true })
|
||||
@Index(['tenantId', 'orderId'])
|
||||
@Index(['tenantId', 'receptorRfc'])
|
||||
export class CFDI {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'config_id', type: 'uuid' })
|
||||
configId: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
|
||||
branchId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CFDIStatus,
|
||||
default: CFDIStatus.DRAFT,
|
||||
})
|
||||
status: CFDIStatus;
|
||||
|
||||
@Column({
|
||||
name: 'tipo_comprobante',
|
||||
type: 'enum',
|
||||
enum: CFDIType,
|
||||
default: CFDIType.INGRESO,
|
||||
})
|
||||
tipoComprobante: CFDIType;
|
||||
|
||||
// CFDI identification
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
uuid: string; // UUID assigned by SAT after stamping
|
||||
|
||||
@Column({ length: 10 })
|
||||
serie: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
folio: number;
|
||||
|
||||
@Column({ name: 'fecha_emision', type: 'timestamp with time zone' })
|
||||
fechaEmision: Date;
|
||||
|
||||
@Column({ name: 'fecha_timbrado', type: 'timestamp with time zone', nullable: true })
|
||||
fechaTimbrado: Date;
|
||||
|
||||
// Emisor (Issuer)
|
||||
@Column({ name: 'emisor_rfc', length: 13 })
|
||||
emisorRfc: string;
|
||||
|
||||
@Column({ name: 'emisor_nombre', length: 300 })
|
||||
emisorNombre: string;
|
||||
|
||||
@Column({ name: 'emisor_regimen_fiscal', length: 3 })
|
||||
emisorRegimenFiscal: string;
|
||||
|
||||
// Receptor (Receiver)
|
||||
@Column({ name: 'receptor_rfc', length: 13 })
|
||||
receptorRfc: string;
|
||||
|
||||
@Column({ name: 'receptor_nombre', length: 300 })
|
||||
receptorNombre: string;
|
||||
|
||||
@Column({ name: 'receptor_uso_cfdi', length: 4 })
|
||||
receptorUsoCFDI: string;
|
||||
|
||||
@Column({ name: 'receptor_regimen_fiscal', length: 3, nullable: true })
|
||||
receptorRegimenFiscal: string;
|
||||
|
||||
@Column({ name: 'receptor_domicilio_fiscal', length: 5, nullable: true })
|
||||
receptorDomicilioFiscal: string;
|
||||
|
||||
@Column({ name: 'receptor_email', length: 100, nullable: true })
|
||||
receptorEmail: string;
|
||||
|
||||
// Amounts
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
subtotal: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
descuento: number;
|
||||
|
||||
@Column({ name: 'total_impuestos_trasladados', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalImpuestosTrasladados: number;
|
||||
|
||||
@Column({ name: 'total_impuestos_retenidos', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalImpuestosRetenidos: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
total: number;
|
||||
|
||||
// Payment info
|
||||
@Column({ name: 'forma_pago', length: 2 })
|
||||
formaPago: string;
|
||||
|
||||
@Column({ name: 'metodo_pago', length: 3 })
|
||||
metodoPago: string;
|
||||
|
||||
@Column({ length: 3, default: 'MXN' })
|
||||
moneda: string;
|
||||
|
||||
@Column({ name: 'tipo_cambio', type: 'decimal', precision: 10, scale: 6, default: 1 })
|
||||
tipoCambio: number;
|
||||
|
||||
@Column({ name: 'condiciones_pago', length: 100, nullable: true })
|
||||
condicionesPago: string;
|
||||
|
||||
// Concepts (line items)
|
||||
@Column({ type: 'jsonb' })
|
||||
conceptos: {
|
||||
claveProdServ: string;
|
||||
claveUnidad: string;
|
||||
cantidad: number;
|
||||
unidad: string;
|
||||
descripcion: string;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
descuento?: number;
|
||||
objetoImp: string;
|
||||
impuestos?: {
|
||||
traslados?: {
|
||||
base: number;
|
||||
impuesto: string;
|
||||
tipoFactor: string;
|
||||
tasaOCuota: number;
|
||||
importe: number;
|
||||
}[];
|
||||
retenciones?: {
|
||||
base: number;
|
||||
impuesto: string;
|
||||
tipoFactor: string;
|
||||
tasaOCuota: number;
|
||||
importe: number;
|
||||
}[];
|
||||
};
|
||||
// Retail-specific
|
||||
productId?: string;
|
||||
orderLineId?: string;
|
||||
}[];
|
||||
|
||||
// Tax summary
|
||||
@Column({ name: 'impuestos', type: 'jsonb', nullable: true })
|
||||
impuestos: {
|
||||
traslados?: {
|
||||
impuesto: string;
|
||||
tipoFactor: string;
|
||||
tasaOCuota: number;
|
||||
importe: number;
|
||||
base: number;
|
||||
}[];
|
||||
retenciones?: {
|
||||
impuesto: string;
|
||||
importe: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
// Related CFDIs
|
||||
@Column({ name: 'cfdi_relacionados', type: 'jsonb', nullable: true })
|
||||
cfdiRelacionados: {
|
||||
tipoRelacion: string;
|
||||
uuids: string[];
|
||||
};
|
||||
|
||||
// Seal and stamps
|
||||
@Column({ name: 'sello_cfdi', type: 'text', nullable: true })
|
||||
selloCFDI: string;
|
||||
|
||||
@Column({ name: 'sello_sat', type: 'text', nullable: true })
|
||||
selloSAT: string;
|
||||
|
||||
@Column({ name: 'certificado_sat', length: 20, nullable: true })
|
||||
certificadoSAT: string;
|
||||
|
||||
@Column({ name: 'cadena_original_sat', type: 'text', nullable: true })
|
||||
cadenaOriginalSAT: string;
|
||||
|
||||
// XML content
|
||||
@Column({ name: 'xml_content', type: 'text', nullable: true })
|
||||
xmlContent: string;
|
||||
|
||||
// PDF
|
||||
@Column({ name: 'pdf_url', length: 255, nullable: true })
|
||||
pdfUrl: string;
|
||||
|
||||
@Column({ name: 'pdf_generated_at', type: 'timestamp with time zone', nullable: true })
|
||||
pdfGeneratedAt: Date;
|
||||
|
||||
// QR code data
|
||||
@Column({ name: 'qr_data', type: 'text', nullable: true })
|
||||
qrData: string;
|
||||
|
||||
// Reference to original order
|
||||
@Column({ name: 'order_id', type: 'uuid', nullable: true })
|
||||
orderId: string;
|
||||
|
||||
@Column({ name: 'order_number', length: 30, nullable: true })
|
||||
orderNumber: string;
|
||||
|
||||
@Column({ name: 'order_type', length: 20, nullable: true })
|
||||
orderType: 'pos' | 'ecommerce';
|
||||
|
||||
// Customer reference
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId: string;
|
||||
|
||||
// Cancellation
|
||||
@Column({ name: 'cancelled_at', type: 'timestamp with time zone', nullable: true })
|
||||
cancelledAt: Date;
|
||||
|
||||
@Column({ name: 'cancelled_by', type: 'uuid', nullable: true })
|
||||
cancelledBy: string;
|
||||
|
||||
@Column({ name: 'cancellation_reason', length: 2, nullable: true })
|
||||
cancellationReason: string; // SAT cancellation code
|
||||
|
||||
@Column({ name: 'cancellation_uuid', type: 'uuid', nullable: true })
|
||||
cancellationUuid: string;
|
||||
|
||||
@Column({ name: 'cancellation_status', length: 50, nullable: true })
|
||||
cancellationStatus: string;
|
||||
|
||||
@Column({ name: 'substitute_uuid', type: 'uuid', nullable: true })
|
||||
substituteUuid: string;
|
||||
|
||||
// Email
|
||||
@Column({ name: 'email_sent', type: 'boolean', default: false })
|
||||
emailSent: boolean;
|
||||
|
||||
@Column({ name: 'email_sent_at', type: 'timestamp with time zone', nullable: true })
|
||||
emailSentAt: Date;
|
||||
|
||||
@Column({ name: 'email_sent_to', length: 100, nullable: true })
|
||||
emailSentTo: string;
|
||||
|
||||
// PAC response
|
||||
@Column({ name: 'pac_response', type: 'jsonb', nullable: true })
|
||||
pacResponse: Record<string, any>;
|
||||
|
||||
// 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<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from './cfdi-config.entity';
|
||||
export * from './cfdi.entity';
|
||||
@ -1,14 +0,0 @@
|
||||
// Services
|
||||
export * from './services';
|
||||
|
||||
// Controllers
|
||||
export * from './controllers';
|
||||
|
||||
// Routes
|
||||
export * from './routes';
|
||||
|
||||
// Validation
|
||||
export * from './validation';
|
||||
|
||||
// Entities
|
||||
export * from './entities';
|
||||
@ -1,222 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { authMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { branchMiddleware } from '../../branches/middleware/branch.middleware';
|
||||
import { validateRequest } from '../../../shared/middleware/validation.middleware';
|
||||
import { requirePermissions } from '../../../shared/middleware/permissions.middleware';
|
||||
import {
|
||||
createCFDISchema,
|
||||
cancelCFDISchema,
|
||||
listCFDIsQuerySchema,
|
||||
resendEmailSchema,
|
||||
createCreditNoteSchema,
|
||||
createCFDIConfigSchema,
|
||||
updateCFDIConfigSchema,
|
||||
validateCFDIConfigSchema,
|
||||
autofacturaRequestSchema,
|
||||
lookupOrderSchema,
|
||||
cfdiStatsQuerySchema,
|
||||
} from '../validation/cfdi.schema';
|
||||
import * as cfdiController from '../controllers/cfdi.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ==================== PUBLIC ROUTES (Autofactura) ====================
|
||||
|
||||
/**
|
||||
* Lookup order for autofactura (public)
|
||||
*/
|
||||
router.post(
|
||||
'/autofactura/lookup',
|
||||
validateRequest(lookupOrderSchema),
|
||||
cfdiController.lookupOrder
|
||||
);
|
||||
|
||||
/**
|
||||
* Create autofactura (public)
|
||||
*/
|
||||
router.post(
|
||||
'/autofactura/create',
|
||||
validateRequest(autofacturaRequestSchema),
|
||||
cfdiController.createAutofactura
|
||||
);
|
||||
|
||||
/**
|
||||
* Get UsoCFDI options (public for forms)
|
||||
*/
|
||||
router.get('/uso-cfdi-options', cfdiController.getUsoCFDIOptions);
|
||||
|
||||
/**
|
||||
* Get common SAT product codes (public for forms)
|
||||
*/
|
||||
router.get('/claves-prod-serv', cfdiController.getClavesProdServ);
|
||||
|
||||
/**
|
||||
* Get cancellation reasons (public for forms)
|
||||
*/
|
||||
router.get('/cancellation-reasons', cfdiController.getCancellationReasons);
|
||||
|
||||
// ==================== AUTHENTICATED ROUTES ====================
|
||||
|
||||
// All routes below require authentication
|
||||
router.use(authMiddleware);
|
||||
|
||||
// ==================== CFDI ROUTES ====================
|
||||
|
||||
/**
|
||||
* List CFDIs with filters
|
||||
*/
|
||||
router.get(
|
||||
'/cfdis',
|
||||
validateRequest(listCFDIsQuerySchema, 'query'),
|
||||
requirePermissions('cfdi:view'),
|
||||
cfdiController.listCFDIs
|
||||
);
|
||||
|
||||
/**
|
||||
* Get CFDI statistics
|
||||
*/
|
||||
router.get(
|
||||
'/stats',
|
||||
validateRequest(cfdiStatsQuerySchema, 'query'),
|
||||
requirePermissions('cfdi:view'),
|
||||
cfdiController.getCFDIStats
|
||||
);
|
||||
|
||||
/**
|
||||
* Get CFDI by ID
|
||||
*/
|
||||
router.get(
|
||||
'/cfdis/:id',
|
||||
requirePermissions('cfdi:view'),
|
||||
cfdiController.getCFDI
|
||||
);
|
||||
|
||||
/**
|
||||
* Get CFDI by UUID
|
||||
*/
|
||||
router.get(
|
||||
'/cfdis/uuid/:uuid',
|
||||
requirePermissions('cfdi:view'),
|
||||
cfdiController.getCFDIByUUID
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new CFDI
|
||||
*/
|
||||
router.post(
|
||||
'/cfdis',
|
||||
branchMiddleware,
|
||||
validateRequest(createCFDISchema),
|
||||
requirePermissions('cfdi:create'),
|
||||
cfdiController.createCFDI
|
||||
);
|
||||
|
||||
/**
|
||||
* Stamp a draft CFDI
|
||||
*/
|
||||
router.post(
|
||||
'/cfdis/:id/stamp',
|
||||
requirePermissions('cfdi:stamp'),
|
||||
cfdiController.stampCFDI
|
||||
);
|
||||
|
||||
/**
|
||||
* Cancel a CFDI
|
||||
*/
|
||||
router.post(
|
||||
'/cfdis/:id/cancel',
|
||||
validateRequest(cancelCFDISchema),
|
||||
requirePermissions('cfdi:cancel'),
|
||||
cfdiController.cancelCFDI
|
||||
);
|
||||
|
||||
/**
|
||||
* Verify CFDI status with SAT
|
||||
*/
|
||||
router.get(
|
||||
'/cfdis/:id/verify',
|
||||
requirePermissions('cfdi:view'),
|
||||
cfdiController.verifyCFDI
|
||||
);
|
||||
|
||||
/**
|
||||
* Download CFDI XML
|
||||
*/
|
||||
router.get(
|
||||
'/cfdis/:id/xml',
|
||||
requirePermissions('cfdi:view'),
|
||||
cfdiController.downloadXML
|
||||
);
|
||||
|
||||
/**
|
||||
* Resend CFDI via email
|
||||
*/
|
||||
router.post(
|
||||
'/cfdis/:id/resend-email',
|
||||
validateRequest(resendEmailSchema),
|
||||
requirePermissions('cfdi:send'),
|
||||
cfdiController.resendEmail
|
||||
);
|
||||
|
||||
/**
|
||||
* Create credit note for a CFDI
|
||||
*/
|
||||
router.post(
|
||||
'/cfdis/:id/credit-note',
|
||||
branchMiddleware,
|
||||
validateRequest(createCreditNoteSchema.omit({ originalCfdiId: true })),
|
||||
requirePermissions('cfdi:create'),
|
||||
cfdiController.createCreditNote
|
||||
);
|
||||
|
||||
// ==================== CONFIG ROUTES ====================
|
||||
|
||||
/**
|
||||
* Get active CFDI configuration
|
||||
*/
|
||||
router.get(
|
||||
'/config',
|
||||
requirePermissions('cfdi:config:view'),
|
||||
cfdiController.getConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* Create or update CFDI configuration
|
||||
*/
|
||||
router.post(
|
||||
'/config',
|
||||
validateRequest(createCFDIConfigSchema),
|
||||
requirePermissions('cfdi:config:manage'),
|
||||
cfdiController.upsertConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* Update CFDI configuration
|
||||
*/
|
||||
router.put(
|
||||
'/config',
|
||||
validateRequest(updateCFDIConfigSchema),
|
||||
requirePermissions('cfdi:config:manage'),
|
||||
cfdiController.upsertConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* Upload certificate (CSD)
|
||||
*/
|
||||
router.post(
|
||||
'/config/certificate',
|
||||
requirePermissions('cfdi:config:manage'),
|
||||
cfdiController.uploadCertificate
|
||||
);
|
||||
|
||||
/**
|
||||
* Validate CFDI configuration
|
||||
*/
|
||||
router.post(
|
||||
'/config/validate',
|
||||
validateRequest(validateCFDIConfigSchema),
|
||||
requirePermissions('cfdi:config:view'),
|
||||
cfdiController.validateConfig
|
||||
);
|
||||
|
||||
export default router;
|
||||
@ -1 +0,0 @@
|
||||
export { default as cfdiRoutes } from './cfdi.routes';
|
||||
@ -1,507 +0,0 @@
|
||||
import { CFDIConfig } from '../entities/cfdi-config.entity';
|
||||
import { CFDIType } from '../entities/cfdi.entity';
|
||||
import { CFDIData, CFDIConcepto, CFDIImpuestos, XMLService } from './xml.service';
|
||||
|
||||
export interface OrderLineData {
|
||||
productId?: string;
|
||||
productCode: string;
|
||||
productName: string;
|
||||
claveProdServ: string; // SAT product/service code
|
||||
claveUnidad: string; // SAT unit code
|
||||
unidad: string; // Unit description
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount?: number;
|
||||
taxRate: number; // e.g., 0.16 for 16% IVA
|
||||
taxIncluded?: boolean; // Whether price includes tax
|
||||
retentionIVA?: number; // IVA retention rate (e.g., 0.106667)
|
||||
retentionISR?: number; // ISR retention rate
|
||||
objetoImp?: string; // Tax object code (01, 02, 03)
|
||||
}
|
||||
|
||||
export interface ReceptorData {
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
usoCFDI: string;
|
||||
regimenFiscal?: string;
|
||||
domicilioFiscal?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface CFDIBuildInput {
|
||||
config: CFDIConfig;
|
||||
receptor: ReceptorData;
|
||||
lines: OrderLineData[];
|
||||
formaPago: string;
|
||||
metodoPago: string;
|
||||
moneda?: string;
|
||||
tipoCambio?: number;
|
||||
condicionesPago?: string;
|
||||
tipoComprobante?: CFDIType;
|
||||
cfdiRelacionados?: {
|
||||
tipoRelacion: string;
|
||||
uuids: string[];
|
||||
};
|
||||
orderId?: string;
|
||||
orderNumber?: string;
|
||||
orderType?: 'pos' | 'ecommerce';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CFDIBuildResult {
|
||||
cfdiData: CFDIData;
|
||||
subtotal: number;
|
||||
descuento: number;
|
||||
totalImpuestosTrasladados: number;
|
||||
totalImpuestosRetenidos: number;
|
||||
total: number;
|
||||
conceptos: CFDIConcepto[];
|
||||
}
|
||||
|
||||
// SAT Tax Types
|
||||
const TAX_IVA = '002';
|
||||
const TAX_ISR = '001';
|
||||
const TAX_IEPS = '003';
|
||||
|
||||
// SAT Tax Factor Types
|
||||
const FACTOR_TASA = 'Tasa';
|
||||
const FACTOR_CUOTA = 'Cuota';
|
||||
const FACTOR_EXENTO = 'Exento';
|
||||
|
||||
// SAT Object of Tax Codes
|
||||
const OBJETO_IMP = {
|
||||
NO_OBJETO: '01', // Not subject to tax
|
||||
SI_OBJETO: '02', // Subject to tax
|
||||
SI_NO_DESGLOSE: '03', // Subject to tax but no breakdown
|
||||
};
|
||||
|
||||
export class CFDIBuilderService {
|
||||
private xmlService: XMLService;
|
||||
|
||||
constructor() {
|
||||
this.xmlService = new XMLService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete CFDI data structure from order data
|
||||
*/
|
||||
buildFromOrder(input: CFDIBuildInput): CFDIBuildResult {
|
||||
const { config, receptor, lines, tipoComprobante = CFDIType.INGRESO } = input;
|
||||
|
||||
// Process each line to calculate taxes
|
||||
const processedLines = lines.map(line => this.processLine(line));
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = processedLines.reduce((sum, l) => sum + l.importe, 0);
|
||||
const descuento = processedLines.reduce((sum, l) => sum + (l.descuento || 0), 0);
|
||||
|
||||
// Aggregate taxes
|
||||
const aggregatedTaxes = this.aggregateTaxes(processedLines);
|
||||
const totalImpuestosTrasladados = aggregatedTaxes.traslados.reduce((sum, t) => sum + t.importe, 0);
|
||||
const totalImpuestosRetenidos = aggregatedTaxes.retenciones.reduce((sum, r) => sum + r.importe, 0);
|
||||
|
||||
const total = subtotal - descuento + totalImpuestosTrasladados - totalImpuestosRetenidos;
|
||||
|
||||
// Build CFDI data structure
|
||||
const cfdiData: CFDIData = {
|
||||
version: '4.0',
|
||||
serie: config.defaultSerie,
|
||||
folio: config.currentFolio,
|
||||
fecha: this.xmlService.formatCFDIDate(new Date()),
|
||||
formaPago: input.formaPago || config.defaultFormaPago,
|
||||
metodoPago: input.metodoPago || config.defaultMetodoPago,
|
||||
tipoDeComprobante: tipoComprobante,
|
||||
exportacion: '01', // No exportación
|
||||
lugarExpedicion: config.codigoPostal,
|
||||
moneda: input.moneda || config.defaultMoneda,
|
||||
tipoCambio: input.tipoCambio,
|
||||
subTotal: this.round(subtotal),
|
||||
descuento: descuento > 0 ? this.round(descuento) : undefined,
|
||||
total: this.round(total),
|
||||
condicionesDePago: input.condicionesPago,
|
||||
emisor: {
|
||||
rfc: config.rfc,
|
||||
nombre: config.razonSocial,
|
||||
regimenFiscal: config.regimenFiscal,
|
||||
},
|
||||
receptor: {
|
||||
rfc: receptor.rfc,
|
||||
nombre: receptor.nombre,
|
||||
usoCFDI: receptor.usoCFDI || config.defaultUsoCFDI,
|
||||
domicilioFiscalReceptor: receptor.domicilioFiscal,
|
||||
regimenFiscalReceptor: receptor.regimenFiscal,
|
||||
},
|
||||
conceptos: processedLines,
|
||||
impuestos: aggregatedTaxes.traslados.length > 0 || aggregatedTaxes.retenciones.length > 0
|
||||
? {
|
||||
traslados: aggregatedTaxes.traslados.length > 0 ? aggregatedTaxes.traslados : undefined,
|
||||
retenciones: aggregatedTaxes.retenciones.length > 0 ? aggregatedTaxes.retenciones : undefined,
|
||||
}
|
||||
: undefined,
|
||||
cfdiRelacionados: input.cfdiRelacionados,
|
||||
};
|
||||
|
||||
return {
|
||||
cfdiData,
|
||||
subtotal: this.round(subtotal),
|
||||
descuento: this.round(descuento),
|
||||
totalImpuestosTrasladados: this.round(totalImpuestosTrasladados),
|
||||
totalImpuestosRetenidos: this.round(totalImpuestosRetenidos),
|
||||
total: this.round(total),
|
||||
conceptos: processedLines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build credit note (Nota de Crédito) CFDI
|
||||
*/
|
||||
buildCreditNote(
|
||||
config: CFDIConfig,
|
||||
receptor: ReceptorData,
|
||||
originalUuid: string,
|
||||
lines: OrderLineData[],
|
||||
reason: string
|
||||
): CFDIBuildResult {
|
||||
const result = this.buildFromOrder({
|
||||
config,
|
||||
receptor,
|
||||
lines,
|
||||
formaPago: '99', // Por definir
|
||||
metodoPago: 'PUE',
|
||||
tipoComprobante: CFDIType.EGRESO,
|
||||
cfdiRelacionados: {
|
||||
tipoRelacion: '01', // Nota de crédito de los documentos relacionados
|
||||
uuids: [originalUuid],
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build payment complement CFDI (Complemento de Pago)
|
||||
*/
|
||||
buildPaymentComplement(
|
||||
config: CFDIConfig,
|
||||
receptor: ReceptorData,
|
||||
relatedCfdis: Array<{
|
||||
uuid: string;
|
||||
serie: string;
|
||||
folio: string;
|
||||
total: number;
|
||||
saldoAnterior: number;
|
||||
importePagado: number;
|
||||
saldoInsoluto: number;
|
||||
}>,
|
||||
paymentData: {
|
||||
fecha: Date;
|
||||
formaPago: string;
|
||||
moneda: string;
|
||||
tipoCambio?: number;
|
||||
monto: number;
|
||||
numOperacion?: string;
|
||||
rfcBancoEmisor?: string;
|
||||
ctaOrdenante?: string;
|
||||
rfcBancoBeneficiario?: string;
|
||||
ctaBeneficiario?: string;
|
||||
}
|
||||
): CFDIData {
|
||||
// Payment CFDIs have special structure
|
||||
const cfdiData: CFDIData = {
|
||||
version: '4.0',
|
||||
serie: config.defaultSerie,
|
||||
folio: config.currentFolio,
|
||||
fecha: this.xmlService.formatCFDIDate(new Date()),
|
||||
formaPago: '', // Not used in payment CFDI
|
||||
metodoPago: '', // Not used
|
||||
tipoDeComprobante: CFDIType.PAGO,
|
||||
exportacion: '01',
|
||||
lugarExpedicion: config.codigoPostal,
|
||||
moneda: 'XXX', // Required for payment CFDIs
|
||||
subTotal: 0,
|
||||
total: 0,
|
||||
emisor: {
|
||||
rfc: config.rfc,
|
||||
nombre: config.razonSocial,
|
||||
regimenFiscal: config.regimenFiscal,
|
||||
},
|
||||
receptor: {
|
||||
rfc: receptor.rfc,
|
||||
nombre: receptor.nombre,
|
||||
usoCFDI: 'CP01', // Por definir - mandatory for payments
|
||||
domicilioFiscalReceptor: receptor.domicilioFiscal,
|
||||
regimenFiscalReceptor: receptor.regimenFiscal,
|
||||
},
|
||||
conceptos: [{
|
||||
claveProdServ: '84111506', // Servicios de facturación
|
||||
claveUnidad: 'ACT', // Actividad
|
||||
cantidad: 1,
|
||||
unidad: 'Actividad',
|
||||
descripcion: 'Pago',
|
||||
valorUnitario: 0,
|
||||
importe: 0,
|
||||
objetoImp: '01',
|
||||
}],
|
||||
};
|
||||
|
||||
// Note: The actual payment complement (Pagos 2.0) would be added as a Complemento
|
||||
// This requires additional XML structure beyond basic CFDI
|
||||
|
||||
return cfdiData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single line item to CFDI concepto
|
||||
*/
|
||||
private processLine(line: OrderLineData): CFDIConcepto {
|
||||
let valorUnitario: number;
|
||||
let importe: number;
|
||||
let taxBase: number;
|
||||
|
||||
if (line.taxIncluded) {
|
||||
// If price includes tax, we need to extract the base price
|
||||
const taxDivisor = 1 + line.taxRate;
|
||||
valorUnitario = line.unitPrice / taxDivisor;
|
||||
importe = valorUnitario * line.quantity;
|
||||
} else {
|
||||
valorUnitario = line.unitPrice;
|
||||
importe = valorUnitario * line.quantity;
|
||||
}
|
||||
|
||||
// Apply discount if any
|
||||
const descuento = line.discount || 0;
|
||||
taxBase = importe - descuento;
|
||||
|
||||
// Build tax structure
|
||||
const traslados: CFDIConcepto['impuestos']['traslados'] = [];
|
||||
const retenciones: CFDIConcepto['impuestos']['retenciones'] = [];
|
||||
|
||||
// Determine object of tax
|
||||
const objetoImp = line.objetoImp || (line.taxRate > 0 ? OBJETO_IMP.SI_OBJETO : OBJETO_IMP.NO_OBJETO);
|
||||
|
||||
// Only add taxes if subject to tax
|
||||
if (objetoImp === OBJETO_IMP.SI_OBJETO) {
|
||||
// IVA traslado
|
||||
if (line.taxRate > 0) {
|
||||
traslados.push({
|
||||
base: this.round(taxBase),
|
||||
impuesto: TAX_IVA,
|
||||
tipoFactor: FACTOR_TASA,
|
||||
tasaOCuota: line.taxRate,
|
||||
importe: this.round(taxBase * line.taxRate),
|
||||
});
|
||||
} else if (line.taxRate === 0) {
|
||||
// Tasa 0%
|
||||
traslados.push({
|
||||
base: this.round(taxBase),
|
||||
impuesto: TAX_IVA,
|
||||
tipoFactor: FACTOR_TASA,
|
||||
tasaOCuota: 0,
|
||||
importe: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// IVA retention
|
||||
if (line.retentionIVA && line.retentionIVA > 0) {
|
||||
retenciones.push({
|
||||
base: this.round(taxBase),
|
||||
impuesto: TAX_IVA,
|
||||
tipoFactor: FACTOR_TASA,
|
||||
tasaOCuota: line.retentionIVA,
|
||||
importe: this.round(taxBase * line.retentionIVA),
|
||||
});
|
||||
}
|
||||
|
||||
// ISR retention
|
||||
if (line.retentionISR && line.retentionISR > 0) {
|
||||
retenciones.push({
|
||||
base: this.round(taxBase),
|
||||
impuesto: TAX_ISR,
|
||||
tipoFactor: FACTOR_TASA,
|
||||
tasaOCuota: line.retentionISR,
|
||||
importe: this.round(taxBase * line.retentionISR),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
claveProdServ: line.claveProdServ,
|
||||
claveUnidad: line.claveUnidad,
|
||||
cantidad: line.quantity,
|
||||
unidad: line.unidad,
|
||||
descripcion: line.productName,
|
||||
valorUnitario: this.round(valorUnitario),
|
||||
importe: this.round(importe),
|
||||
descuento: descuento > 0 ? this.round(descuento) : undefined,
|
||||
objetoImp,
|
||||
impuestos: (traslados.length > 0 || retenciones.length > 0) ? {
|
||||
traslados: traslados.length > 0 ? traslados : undefined,
|
||||
retenciones: retenciones.length > 0 ? retenciones : undefined,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate taxes from all conceptos
|
||||
*/
|
||||
private aggregateTaxes(conceptos: CFDIConcepto[]): CFDIImpuestos {
|
||||
const trasladosMap = new Map<string, {
|
||||
impuesto: string;
|
||||
tipoFactor: string;
|
||||
tasaOCuota: number;
|
||||
importe: number;
|
||||
base: number;
|
||||
}>();
|
||||
|
||||
const retencionesMap = new Map<string, {
|
||||
impuesto: string;
|
||||
importe: number;
|
||||
}>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,815 +0,0 @@
|
||||
import { DataSource, Repository, QueryRunner } from 'typeorm';
|
||||
import { BaseService } from '../../../shared/services/base.service';
|
||||
import { CFDI, CFDIStatus, CFDIType } from '../entities/cfdi.entity';
|
||||
import { CFDIConfig, CFDIConfigStatus } from '../entities/cfdi-config.entity';
|
||||
import { XMLService } from './xml.service';
|
||||
import { PACService, CANCELLATION_REASONS } from './pac.service';
|
||||
import { CFDIBuilderService, OrderLineData, ReceptorData } from './cfdi-builder.service';
|
||||
|
||||
export interface ServiceResult<T> {
|
||||
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<CFDI> {
|
||||
private xmlService: XMLService;
|
||||
private pacService: PACService;
|
||||
private builderService: CFDIBuilderService;
|
||||
private configRepository: Repository<CFDIConfig>;
|
||||
|
||||
constructor(
|
||||
dataSource: DataSource,
|
||||
repository: Repository<CFDI>
|
||||
) {
|
||||
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<ServiceResult<CFDIConfig>> {
|
||||
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<ServiceResult<CFDI>> {
|
||||
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<ServiceResult<CFDI>> {
|
||||
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<ServiceResult<CFDI>> {
|
||||
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<ServiceResult<{ status: string; cancellable: boolean }>> {
|
||||
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<ServiceResult<CFDI>> {
|
||||
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<ServiceResult<CFDI>> {
|
||||
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<ServiceResult<{ xml: string; filename: string }>> {
|
||||
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<void> {
|
||||
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<ServiceResult<void>> {
|
||||
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<ServiceResult<CFDI>> {
|
||||
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<CFDIStatus, number>;
|
||||
}> {
|
||||
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<CFDIStatus, number>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export * from './cfdi.service';
|
||||
export * from './cfdi-builder.service';
|
||||
export * from './xml.service';
|
||||
export * from './pac.service';
|
||||
@ -1,755 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
import { CFDIConfig, PACProvider } from '../entities/cfdi-config.entity';
|
||||
|
||||
export interface StampResult {
|
||||
success: boolean;
|
||||
uuid?: string;
|
||||
fechaTimbrado?: string;
|
||||
xml?: string;
|
||||
acuseRecepcion?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface CancelResult {
|
||||
success: boolean;
|
||||
acuse?: string;
|
||||
status?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface VerifyStatusResult {
|
||||
success: boolean;
|
||||
status: 'valid' | 'cancelled' | 'not_found' | 'error';
|
||||
cancellationStatus?: string;
|
||||
cancellationDate?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface PACEndpoints {
|
||||
stamp: string;
|
||||
cancel: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const PAC_ENDPOINTS: Record<PACProvider, { sandbox: PACEndpoints; production: PACEndpoints }> = {
|
||||
[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<StampResult> {
|
||||
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<CancelResult> {
|
||||
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<VerifyStatusResult> {
|
||||
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<StampResult> {
|
||||
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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
xmlns:stam="http://facturacion.finkok.com/stamp">
|
||||
<soapenv:Header/>
|
||||
<soapenv:Body>
|
||||
<stam:stamp>
|
||||
<stam:xml>${xmlBase64}</stam:xml>
|
||||
<stam:username>${username}</stam:username>
|
||||
<stam:password>${password}</stam:password>
|
||||
</stam:stamp>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>`;
|
||||
}
|
||||
|
||||
private parseFinkokStampResponse(responseXml: string): StampResult {
|
||||
// Check for Incidencias (errors)
|
||||
const incidenciaMatch = responseXml.match(/<CodigoError>([^<]+)<\/CodigoError>[\s\S]*?<MensajeIncidencia>([^<]+)<\/MensajeIncidencia>/);
|
||||
if (incidenciaMatch) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: incidenciaMatch[1],
|
||||
errorMessage: incidenciaMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Extract stamped XML
|
||||
const xmlMatch = responseXml.match(/<xml>([^<]+)<\/xml>/);
|
||||
const uuidMatch = responseXml.match(/<UUID>([^<]+)<\/UUID>/);
|
||||
const fechaMatch = responseXml.match(/<Fecha>([^<]+)<\/Fecha>/);
|
||||
const acuseMatch = responseXml.match(/<Acuse>([^<]+)<\/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<CancelResult> {
|
||||
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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
xmlns:can="http://facturacion.finkok.com/cancel">
|
||||
<soapenv:Header/>
|
||||
<soapenv:Body>
|
||||
<can:cancel>
|
||||
<can:UUIDS>
|
||||
<can:string>${uuid}</can:string>
|
||||
</can:UUIDS>
|
||||
<can:username>${username}</can:username>
|
||||
<can:password>${password}</can:password>
|
||||
<can:taxpayer_id>${rfc}</can:taxpayer_id>
|
||||
<can:motivo>${reason}</can:motivo>
|
||||
<can:folio_sustitucion>${folioSustitucion}</can:folio_sustitucion>
|
||||
</can:cancel>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>`;
|
||||
}
|
||||
|
||||
private parseFinkokCancelResponse(responseXml: string): CancelResult {
|
||||
const statusMatch = responseXml.match(/<EstatusUUID>([^<]+)<\/EstatusUUID>/);
|
||||
const acuseMatch = responseXml.match(/<Acuse>([^<]+)<\/Acuse>/);
|
||||
const errorMatch = responseXml.match(/<CodEstatus>([^<]+)<\/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<VerifyStatusResult> {
|
||||
const credentials = this.decryptCredentials(config);
|
||||
const endpoint = this.getEndpoint(config, 'status');
|
||||
|
||||
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
xmlns:uti="http://facturacion.finkok.com/utilities">
|
||||
<soapenv:Header/>
|
||||
<soapenv:Body>
|
||||
<uti:get_sat_status>
|
||||
<uti:username>${credentials.username}</uti:username>
|
||||
<uti:password>${credentials.password}</uti:password>
|
||||
<uti:taxpayer_id>${emisorRfc}</uti:taxpayer_id>
|
||||
<uti:rtaxpayer_id>${receptorRfc}</uti:rtaxpayer_id>
|
||||
<uti:uuid>${uuid}</uti:uuid>
|
||||
<uti:total>${total.toFixed(2)}</uti:total>
|
||||
</uti:get_sat_status>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>`;
|
||||
|
||||
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>([^<]+)<\/Estado>/);
|
||||
const cancelableMatch = responseXml.match(/<EsCancelable>([^<]+)<\/EsCancelable>/);
|
||||
const estatusCancelacionMatch = responseXml.match(/<EstatusCancelacion>([^<]+)<\/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<StampResult> {
|
||||
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<CancelResult> {
|
||||
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<StampResult> {
|
||||
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<CancelResult> {
|
||||
// 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<StampResult> {
|
||||
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<CancelResult> {
|
||||
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<VerifyStatusResult> {
|
||||
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<string> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
@ -1,534 +0,0 @@
|
||||
import { create, fragment } from 'xmlbuilder2';
|
||||
import { CFDI, CFDIType } from '../entities/cfdi.entity';
|
||||
import { CFDIConfig } from '../entities/cfdi-config.entity';
|
||||
|
||||
export interface CFDIConcepto {
|
||||
claveProdServ: string;
|
||||
claveUnidad: string;
|
||||
cantidad: number;
|
||||
unidad: string;
|
||||
descripcion: string;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
descuento?: number;
|
||||
objetoImp: string;
|
||||
impuestos?: {
|
||||
traslados?: {
|
||||
base: number;
|
||||
impuesto: string;
|
||||
tipoFactor: string;
|
||||
tasaOCuota: number;
|
||||
importe: number;
|
||||
}[];
|
||||
retenciones?: {
|
||||
base: number;
|
||||
impuesto: string;
|
||||
tipoFactor: string;
|
||||
tasaOCuota: number;
|
||||
importe: number;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface CFDIImpuestos {
|
||||
traslados?: {
|
||||
impuesto: string;
|
||||
tipoFactor: string;
|
||||
tasaOCuota: number;
|
||||
importe: number;
|
||||
base: number;
|
||||
}[];
|
||||
retenciones?: {
|
||||
impuesto: string;
|
||||
importe: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CFDIData {
|
||||
version: string;
|
||||
serie: string;
|
||||
folio: number;
|
||||
fecha: string;
|
||||
formaPago: string;
|
||||
metodoPago: string;
|
||||
tipoDeComprobante: string;
|
||||
exportacion: string;
|
||||
lugarExpedicion: string;
|
||||
moneda: string;
|
||||
tipoCambio?: number;
|
||||
subTotal: number;
|
||||
descuento?: number;
|
||||
total: number;
|
||||
condicionesDePago?: string;
|
||||
emisor: {
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
regimenFiscal: string;
|
||||
};
|
||||
receptor: {
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
usoCFDI: string;
|
||||
domicilioFiscalReceptor?: string;
|
||||
regimenFiscalReceptor?: string;
|
||||
};
|
||||
conceptos: CFDIConcepto[];
|
||||
impuestos?: CFDIImpuestos;
|
||||
cfdiRelacionados?: {
|
||||
tipoRelacion: string;
|
||||
uuids: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimbreData {
|
||||
version: string;
|
||||
uuid: string;
|
||||
fechaTimbrado: string;
|
||||
rfcProvCertif: string;
|
||||
selloCFD: string;
|
||||
noCertificadoSAT: string;
|
||||
selloSAT: string;
|
||||
}
|
||||
|
||||
const CFDI_NAMESPACE = 'http://www.sat.gob.mx/cfd/4';
|
||||
const TIMBRE_NAMESPACE = 'http://www.sat.gob.mx/TimbreFiscalDigital';
|
||||
const XSI_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance';
|
||||
|
||||
export class XMLService {
|
||||
/**
|
||||
* Build CFDI XML without stamps (for signing)
|
||||
*/
|
||||
buildCFDIXML(data: CFDIData, certificateNumber: string): string {
|
||||
const schemaLocation = `${CFDI_NAMESPACE} http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd`;
|
||||
|
||||
const doc = create({ version: '1.0', encoding: 'UTF-8' })
|
||||
.ele(CFDI_NAMESPACE, 'cfdi:Comprobante')
|
||||
.att('xmlns:cfdi', CFDI_NAMESPACE)
|
||||
.att('xmlns:xsi', XSI_NAMESPACE)
|
||||
.att('xsi:schemaLocation', schemaLocation)
|
||||
.att('Version', data.version)
|
||||
.att('Serie', data.serie)
|
||||
.att('Folio', data.folio.toString())
|
||||
.att('Fecha', data.fecha)
|
||||
.att('FormaPago', data.formaPago)
|
||||
.att('SubTotal', this.formatMoney(data.subTotal))
|
||||
.att('Moneda', data.moneda)
|
||||
.att('Total', this.formatMoney(data.total))
|
||||
.att('TipoDeComprobante', data.tipoDeComprobante)
|
||||
.att('Exportacion', data.exportacion)
|
||||
.att('MetodoPago', data.metodoPago)
|
||||
.att('LugarExpedicion', data.lugarExpedicion)
|
||||
.att('NoCertificado', certificateNumber);
|
||||
|
||||
if (data.tipoCambio && data.tipoCambio !== 1) {
|
||||
doc.att('TipoCambio', data.tipoCambio.toString());
|
||||
}
|
||||
|
||||
if (data.descuento && data.descuento > 0) {
|
||||
doc.att('Descuento', this.formatMoney(data.descuento));
|
||||
}
|
||||
|
||||
if (data.condicionesDePago) {
|
||||
doc.att('CondicionesDePago', data.condicionesDePago);
|
||||
}
|
||||
|
||||
// CFDI Relacionados
|
||||
if (data.cfdiRelacionados && data.cfdiRelacionados.uuids.length > 0) {
|
||||
const relacionados = doc.ele('cfdi:CfdiRelacionados')
|
||||
.att('TipoRelacion', data.cfdiRelacionados.tipoRelacion);
|
||||
|
||||
for (const uuid of data.cfdiRelacionados.uuids) {
|
||||
relacionados.ele('cfdi:CfdiRelacionado').att('UUID', uuid);
|
||||
}
|
||||
}
|
||||
|
||||
// Emisor
|
||||
doc.ele('cfdi:Emisor')
|
||||
.att('Rfc', data.emisor.rfc)
|
||||
.att('Nombre', data.emisor.nombre)
|
||||
.att('RegimenFiscal', data.emisor.regimenFiscal);
|
||||
|
||||
// Receptor
|
||||
const receptor = doc.ele('cfdi:Receptor')
|
||||
.att('Rfc', data.receptor.rfc)
|
||||
.att('Nombre', data.receptor.nombre)
|
||||
.att('UsoCFDI', data.receptor.usoCFDI);
|
||||
|
||||
if (data.receptor.domicilioFiscalReceptor) {
|
||||
receptor.att('DomicilioFiscalReceptor', data.receptor.domicilioFiscalReceptor);
|
||||
}
|
||||
if (data.receptor.regimenFiscalReceptor) {
|
||||
receptor.att('RegimenFiscalReceptor', data.receptor.regimenFiscalReceptor);
|
||||
}
|
||||
|
||||
// Conceptos
|
||||
const conceptos = doc.ele('cfdi:Conceptos');
|
||||
for (const concepto of data.conceptos) {
|
||||
const conceptoNode = conceptos.ele('cfdi:Concepto')
|
||||
.att('ClaveProdServ', concepto.claveProdServ)
|
||||
.att('Cantidad', concepto.cantidad.toString())
|
||||
.att('ClaveUnidad', concepto.claveUnidad)
|
||||
.att('Unidad', concepto.unidad)
|
||||
.att('Descripcion', concepto.descripcion)
|
||||
.att('ValorUnitario', this.formatMoney(concepto.valorUnitario))
|
||||
.att('Importe', this.formatMoney(concepto.importe))
|
||||
.att('ObjetoImp', concepto.objetoImp);
|
||||
|
||||
if (concepto.descuento && concepto.descuento > 0) {
|
||||
conceptoNode.att('Descuento', this.formatMoney(concepto.descuento));
|
||||
}
|
||||
|
||||
// Impuestos del concepto
|
||||
if (concepto.impuestos) {
|
||||
const impuestosNode = conceptoNode.ele('cfdi:Impuestos');
|
||||
|
||||
if (concepto.impuestos.traslados && concepto.impuestos.traslados.length > 0) {
|
||||
const trasladosNode = impuestosNode.ele('cfdi:Traslados');
|
||||
for (const traslado of concepto.impuestos.traslados) {
|
||||
trasladosNode.ele('cfdi:Traslado')
|
||||
.att('Base', this.formatMoney(traslado.base))
|
||||
.att('Impuesto', traslado.impuesto)
|
||||
.att('TipoFactor', traslado.tipoFactor)
|
||||
.att('TasaOCuota', this.formatRate(traslado.tasaOCuota))
|
||||
.att('Importe', this.formatMoney(traslado.importe));
|
||||
}
|
||||
}
|
||||
|
||||
if (concepto.impuestos.retenciones && concepto.impuestos.retenciones.length > 0) {
|
||||
const retencionesNode = impuestosNode.ele('cfdi:Retenciones');
|
||||
for (const retencion of concepto.impuestos.retenciones) {
|
||||
retencionesNode.ele('cfdi:Retencion')
|
||||
.att('Base', this.formatMoney(retencion.base))
|
||||
.att('Impuesto', retencion.impuesto)
|
||||
.att('TipoFactor', retencion.tipoFactor)
|
||||
.att('TasaOCuota', this.formatRate(retencion.tasaOCuota))
|
||||
.att('Importe', this.formatMoney(retencion.importe));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Impuestos generales
|
||||
if (data.impuestos) {
|
||||
const impuestosNode = doc.ele('cfdi:Impuestos');
|
||||
|
||||
if (data.impuestos.retenciones && data.impuestos.retenciones.length > 0) {
|
||||
const totalRetenido = data.impuestos.retenciones.reduce((sum, r) => sum + r.importe, 0);
|
||||
impuestosNode.att('TotalImpuestosRetenidos', this.formatMoney(totalRetenido));
|
||||
|
||||
const retencionesNode = impuestosNode.ele('cfdi:Retenciones');
|
||||
for (const retencion of data.impuestos.retenciones) {
|
||||
retencionesNode.ele('cfdi:Retencion')
|
||||
.att('Impuesto', retencion.impuesto)
|
||||
.att('Importe', this.formatMoney(retencion.importe));
|
||||
}
|
||||
}
|
||||
|
||||
if (data.impuestos.traslados && data.impuestos.traslados.length > 0) {
|
||||
const totalTrasladado = data.impuestos.traslados.reduce((sum, t) => sum + t.importe, 0);
|
||||
impuestosNode.att('TotalImpuestosTrasladados', this.formatMoney(totalTrasladado));
|
||||
|
||||
const trasladosNode = impuestosNode.ele('cfdi:Traslados');
|
||||
for (const traslado of data.impuestos.traslados) {
|
||||
trasladosNode.ele('cfdi:Traslado')
|
||||
.att('Base', this.formatMoney(traslado.base))
|
||||
.att('Impuesto', traslado.impuesto)
|
||||
.att('TipoFactor', traslado.tipoFactor)
|
||||
.att('TasaOCuota', this.formatRate(traslado.tasaOCuota))
|
||||
.att('Importe', this.formatMoney(traslado.importe));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doc.end({ prettyPrint: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add seal (sello) to XML
|
||||
*/
|
||||
addSealToXML(xml: string, sello: string): string {
|
||||
// Insert Sello attribute after NoCertificado
|
||||
return xml.replace(
|
||||
/NoCertificado="([^"]+)"/,
|
||||
`NoCertificado="$1" Sello="${sello}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add certificate to XML
|
||||
*/
|
||||
addCertificateToXML(xml: string, certificateBase64: string): string {
|
||||
// Insert Certificado attribute after Sello
|
||||
return xml.replace(
|
||||
/Sello="([^"]+)"/,
|
||||
`Sello="$1" Certificado="${certificateBase64}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add TimbreFiscalDigital complement to stamped XML
|
||||
*/
|
||||
addTimbreToXML(xml: string, timbre: TimbreData): string {
|
||||
const timbreFragment = fragment()
|
||||
.ele(TIMBRE_NAMESPACE, 'tfd:TimbreFiscalDigital')
|
||||
.att('xmlns:tfd', TIMBRE_NAMESPACE)
|
||||
.att('xmlns:xsi', XSI_NAMESPACE)
|
||||
.att('xsi:schemaLocation', `${TIMBRE_NAMESPACE} http://www.sat.gob.mx/sitio_internet/cfd/TimbreFiscalDigital/TimbreFiscalDigitalv11.xsd`)
|
||||
.att('Version', timbre.version)
|
||||
.att('UUID', timbre.uuid)
|
||||
.att('FechaTimbrado', timbre.fechaTimbrado)
|
||||
.att('RfcProvCertif', timbre.rfcProvCertif)
|
||||
.att('SelloCFD', timbre.selloCFD)
|
||||
.att('NoCertificadoSAT', timbre.noCertificadoSAT)
|
||||
.att('SelloSAT', timbre.selloSAT)
|
||||
.end();
|
||||
|
||||
// Find closing tag and insert Complemento before it
|
||||
const insertPoint = xml.indexOf('</cfdi:Comprobante>');
|
||||
if (insertPoint === -1) {
|
||||
throw new Error('Invalid CFDI XML: missing closing tag');
|
||||
}
|
||||
|
||||
return xml.slice(0, insertPoint) +
|
||||
'<cfdi:Complemento>' + timbreFragment + '</cfdi:Complemento>' +
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,245 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
uuidSchema,
|
||||
moneySchema,
|
||||
paginationSchema,
|
||||
} from '../../../shared/validation/common.schema';
|
||||
|
||||
// RFC validation (12-13 characters)
|
||||
const rfcSchema = z.string()
|
||||
.min(12, 'RFC must be at least 12 characters')
|
||||
.max(13, 'RFC must be at most 13 characters')
|
||||
.regex(/^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/i, 'Invalid RFC format');
|
||||
|
||||
// SAT catalog codes
|
||||
const claveProdServSchema = z.string().length(8, 'ClaveProdServ must be 8 digits').regex(/^\d{8}$/);
|
||||
const claveUnidadSchema = z.string().min(1).max(4);
|
||||
const regimenFiscalSchema = z.string().length(3, 'RegimenFiscal must be 3 characters');
|
||||
const usoCFDISchema = z.string().length(3).or(z.string().length(4));
|
||||
const codigoPostalSchema = z.string().length(5, 'Código postal must be 5 digits').regex(/^\d{5}$/);
|
||||
const formaPagoSchema = z.string().length(2, 'FormaPago must be 2 characters');
|
||||
const metodoPagoSchema = z.enum(['PUE', 'PPD']);
|
||||
const monedaSchema = z.string().length(3, 'Moneda must be 3 characters').default('MXN');
|
||||
|
||||
// CFDI Status enum
|
||||
export const cfdiStatusEnum = z.enum([
|
||||
'draft',
|
||||
'pending',
|
||||
'stamped',
|
||||
'cancelled',
|
||||
'cancellation_pending',
|
||||
'error',
|
||||
]);
|
||||
|
||||
// CFDI Type enum
|
||||
export const cfdiTypeEnum = z.enum(['I', 'E', 'T', 'N', 'P']);
|
||||
|
||||
// Cancellation reason enum
|
||||
export const cancellationReasonEnum = z.enum(['01', '02', '03', '04']);
|
||||
|
||||
// ==================== RECEPTOR SCHEMAS ====================
|
||||
|
||||
export const receptorSchema = z.object({
|
||||
rfc: rfcSchema,
|
||||
nombre: z.string().min(1).max(300),
|
||||
usoCFDI: usoCFDISchema,
|
||||
regimenFiscal: regimenFiscalSchema.optional(),
|
||||
domicilioFiscal: codigoPostalSchema.optional(),
|
||||
email: z.string().email().max(100).optional(),
|
||||
});
|
||||
|
||||
// ==================== LINE ITEM SCHEMAS ====================
|
||||
|
||||
export const orderLineSchema = z.object({
|
||||
productId: uuidSchema.optional(),
|
||||
productCode: z.string().min(1).max(50),
|
||||
productName: z.string().min(1).max(1000),
|
||||
claveProdServ: claveProdServSchema,
|
||||
claveUnidad: claveUnidadSchema,
|
||||
unidad: z.string().min(1).max(20),
|
||||
quantity: z.number().positive(),
|
||||
unitPrice: moneySchema.positive(),
|
||||
discount: moneySchema.optional(),
|
||||
taxRate: z.number().min(0).max(1).default(0.16),
|
||||
taxIncluded: z.boolean().optional(),
|
||||
retentionIVA: z.number().min(0).max(1).optional(),
|
||||
retentionISR: z.number().min(0).max(1).optional(),
|
||||
objetoImp: z.enum(['01', '02', '03']).optional(),
|
||||
});
|
||||
|
||||
// ==================== CREATE CFDI SCHEMA ====================
|
||||
|
||||
export const createCFDISchema = z.object({
|
||||
configId: uuidSchema.optional(),
|
||||
branchId: uuidSchema.optional(),
|
||||
receptor: receptorSchema,
|
||||
lines: z.array(orderLineSchema).min(1, 'At least one line item is required'),
|
||||
formaPago: formaPagoSchema,
|
||||
metodoPago: metodoPagoSchema,
|
||||
moneda: monedaSchema.optional(),
|
||||
tipoCambio: z.number().positive().optional(),
|
||||
condicionesPago: z.string().max(100).optional(),
|
||||
orderId: uuidSchema.optional(),
|
||||
orderNumber: z.string().max(30).optional(),
|
||||
orderType: z.enum(['pos', 'ecommerce']).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
stampImmediately: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
// ==================== CANCEL CFDI SCHEMA ====================
|
||||
|
||||
export const cancelCFDISchema = z.object({
|
||||
reason: cancellationReasonEnum,
|
||||
substituteUuid: z.string().uuid().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// If reason is 01, substituteUuid is required
|
||||
if (data.reason === '01' && !data.substituteUuid) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Substitute UUID is required for cancellation reason 01',
|
||||
path: ['substituteUuid'],
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== LIST CFDI QUERY SCHEMA ====================
|
||||
|
||||
export const listCFDIsQuerySchema = paginationSchema.extend({
|
||||
status: cfdiStatusEnum.optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
receptorRfc: rfcSchema.optional(),
|
||||
branchId: uuidSchema.optional(),
|
||||
search: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
// ==================== RESEND EMAIL SCHEMA ====================
|
||||
|
||||
export const resendEmailSchema = z.object({
|
||||
email: z.string().email().max(100).optional(),
|
||||
});
|
||||
|
||||
// ==================== CREATE CREDIT NOTE SCHEMA ====================
|
||||
|
||||
export const createCreditNoteSchema = z.object({
|
||||
originalCfdiId: uuidSchema,
|
||||
lines: z.array(orderLineSchema).min(1),
|
||||
reason: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
// ==================== CFDI CONFIG SCHEMAS ====================
|
||||
|
||||
export const pacProviderEnum = z.enum([
|
||||
'finkok',
|
||||
'soluciones_fiscales',
|
||||
'facturapi',
|
||||
'sw_sapien',
|
||||
'digisat',
|
||||
'other',
|
||||
]);
|
||||
|
||||
export const cfdiConfigStatusEnum = z.enum([
|
||||
'active',
|
||||
'inactive',
|
||||
'pending_validation',
|
||||
'expired',
|
||||
]);
|
||||
|
||||
export const createCFDIConfigSchema = z.object({
|
||||
branchId: uuidSchema.optional(),
|
||||
rfc: rfcSchema,
|
||||
razonSocial: z.string().min(1).max(300),
|
||||
nombreComercial: z.string().max(200).optional(),
|
||||
regimenFiscal: regimenFiscalSchema,
|
||||
regimenFiscalDescripcion: z.string().max(200).optional(),
|
||||
codigoPostal: codigoPostalSchema,
|
||||
domicilio: z.string().max(500).optional(),
|
||||
|
||||
// PAC configuration
|
||||
pacProvider: pacProviderEnum.default('finkok'),
|
||||
pacUsername: z.string().max(100).optional(),
|
||||
pacPassword: z.string().max(255).optional(),
|
||||
pacEnvironment: z.enum(['sandbox', 'production']).default('sandbox'),
|
||||
pacApiUrl: z.string().url().max(255).optional(),
|
||||
|
||||
// Certificate (base64 encoded)
|
||||
certificate: z.string().optional(),
|
||||
privateKey: z.string().optional(),
|
||||
certificatePassword: z.string().max(255).optional(),
|
||||
|
||||
// Defaults
|
||||
defaultSerie: z.string().min(1).max(10).default('A'),
|
||||
defaultFormaPago: formaPagoSchema.default('01'),
|
||||
defaultMetodoPago: metodoPagoSchema.default('PUE'),
|
||||
defaultUsoCFDI: usoCFDISchema.default('G03'),
|
||||
defaultMoneda: monedaSchema.default('MXN'),
|
||||
|
||||
// Branding
|
||||
logoUrl: z.string().url().max(255).optional(),
|
||||
pdfTemplate: z.string().max(50).default('standard'),
|
||||
footerText: z.string().max(500).optional(),
|
||||
|
||||
// Email settings
|
||||
sendEmailOnIssue: z.boolean().default(true),
|
||||
emailFrom: z.string().email().max(100).optional(),
|
||||
emailSubjectTemplate: z.string().max(200).optional(),
|
||||
emailBodyTemplate: z.string().max(2000).optional(),
|
||||
|
||||
// Notifications
|
||||
notifyCertificateExpiry: z.boolean().default(true),
|
||||
notifyDaysBeforeExpiry: z.number().int().min(1).max(90).default(30),
|
||||
|
||||
// Auto-invoicing
|
||||
autoInvoicePOS: z.boolean().default(false),
|
||||
autoInvoiceEcommerce: z.boolean().default(true),
|
||||
|
||||
// Rate limiting
|
||||
maxInvoicesPerDay: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const updateCFDIConfigSchema = createCFDIConfigSchema.partial();
|
||||
|
||||
export const validateCFDIConfigSchema = z.object({
|
||||
testStamp: z.boolean().default(false),
|
||||
});
|
||||
|
||||
// ==================== AUTOFACTURA SCHEMAS ====================
|
||||
|
||||
export const autofacturaRequestSchema = z.object({
|
||||
orderNumber: z.string().min(1).max(30),
|
||||
orderDate: z.coerce.date().optional(),
|
||||
branchId: uuidSchema.optional(),
|
||||
receptor: receptorSchema,
|
||||
});
|
||||
|
||||
export const lookupOrderSchema = z.object({
|
||||
orderNumber: z.string().min(1).max(30),
|
||||
orderDate: z.coerce.date().optional(),
|
||||
orderAmount: moneySchema.positive().optional(),
|
||||
});
|
||||
|
||||
// ==================== STATS QUERY SCHEMA ====================
|
||||
|
||||
export const cfdiStatsQuerySchema = z.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
branchId: uuidSchema.optional(),
|
||||
});
|
||||
|
||||
// ==================== TYPES ====================
|
||||
|
||||
export type CreateCFDIInput = z.infer<typeof createCFDISchema>;
|
||||
export type CancelCFDIInput = z.infer<typeof cancelCFDISchema>;
|
||||
export type ListCFDIsQuery = z.infer<typeof listCFDIsQuerySchema>;
|
||||
export type ResendEmailInput = z.infer<typeof resendEmailSchema>;
|
||||
export type CreateCreditNoteInput = z.infer<typeof createCreditNoteSchema>;
|
||||
export type CreateCFDIConfigInput = z.infer<typeof createCFDIConfigSchema>;
|
||||
export type UpdateCFDIConfigInput = z.infer<typeof updateCFDIConfigSchema>;
|
||||
export type AutofacturaRequest = z.infer<typeof autofacturaRequestSchema>;
|
||||
export type LookupOrderInput = z.infer<typeof lookupOrderSchema>;
|
||||
export type CFDIStatsQuery = z.infer<typeof cfdiStatsQuerySchema>;
|
||||
export type ReceptorInput = z.infer<typeof receptorSchema>;
|
||||
export type OrderLineInput = z.infer<typeof orderLineSchema>;
|
||||
@ -1 +0,0 @@
|
||||
export * from './cfdi.schema';
|
||||
@ -1,605 +0,0 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { BaseController } from '../../../shared/controllers/base.controller';
|
||||
import { AuthenticatedRequest } from '../../../shared/types';
|
||||
import { posSessionService } from '../services/pos-session.service';
|
||||
import { posOrderService } from '../services/pos-order.service';
|
||||
|
||||
class POSController extends BaseController {
|
||||
// ==================== SESSION ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* POST /pos/sessions/open - Open a new session
|
||||
*/
|
||||
async openSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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<Response | void> {
|
||||
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();
|
||||
@ -1,4 +0,0 @@
|
||||
export * from './pos-session.entity';
|
||||
export * from './pos-order.entity';
|
||||
export * from './pos-order-line.entity';
|
||||
export * from './pos-payment.entity';
|
||||
@ -1,173 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { POSOrder } from './pos-order.entity';
|
||||
|
||||
export enum LineStatus {
|
||||
ACTIVE = 'active',
|
||||
VOIDED = 'voided',
|
||||
RETURNED = 'returned',
|
||||
}
|
||||
|
||||
@Entity('pos_order_lines', { schema: 'retail' })
|
||||
@Index(['tenantId', 'orderId'])
|
||||
@Index(['tenantId', 'productId'])
|
||||
export class POSOrderLine {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'order_id', type: 'uuid' })
|
||||
orderId: string;
|
||||
|
||||
@Column({ name: 'line_number', type: 'int' })
|
||||
lineNumber: number;
|
||||
|
||||
// Product info (from erp-core inventory)
|
||||
@Column({ name: 'product_id', type: 'uuid' })
|
||||
productId: string;
|
||||
|
||||
@Column({ name: 'product_code', length: 50 })
|
||||
productCode: string;
|
||||
|
||||
@Column({ name: 'product_name', length: 200 })
|
||||
productName: string;
|
||||
|
||||
@Column({ name: 'product_barcode', length: 50, nullable: true })
|
||||
productBarcode: string;
|
||||
|
||||
// Variant info (if applicable)
|
||||
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
|
||||
variantId: string;
|
||||
|
||||
@Column({ name: 'variant_name', length: 200, nullable: true })
|
||||
variantName: string;
|
||||
|
||||
// Quantity
|
||||
@Column({ type: 'decimal', precision: 15, scale: 4, default: 1 })
|
||||
quantity: number;
|
||||
|
||||
@Column({ name: 'uom_id', type: 'uuid', nullable: true })
|
||||
uomId: string;
|
||||
|
||||
@Column({ name: 'uom_name', length: 20, default: 'PZA' })
|
||||
uomName: string;
|
||||
|
||||
// Pricing
|
||||
@Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 })
|
||||
unitPrice: number;
|
||||
|
||||
@Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 })
|
||||
originalPrice: number;
|
||||
|
||||
@Column({ name: 'price_list_id', type: 'uuid', nullable: true })
|
||||
priceListId: string;
|
||||
|
||||
// Line discounts
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'discount_reason', length: 255, nullable: true })
|
||||
discountReason: string;
|
||||
|
||||
// Promotion applied
|
||||
@Column({ name: 'promotion_id', type: 'uuid', nullable: true })
|
||||
promotionId: string;
|
||||
|
||||
@Column({ name: 'promotion_name', length: 100, nullable: true })
|
||||
promotionName: string;
|
||||
|
||||
@Column({ name: 'promotion_discount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
promotionDiscount: number;
|
||||
|
||||
// Tax
|
||||
@Column({ name: 'tax_id', type: 'uuid', nullable: true })
|
||||
taxId: string;
|
||||
|
||||
@Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 })
|
||||
taxRate: number;
|
||||
|
||||
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
taxAmount: number;
|
||||
|
||||
@Column({ name: 'tax_included', type: 'boolean', default: true })
|
||||
taxIncluded: boolean;
|
||||
|
||||
// Totals
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
subtotal: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
total: number;
|
||||
|
||||
// Cost (for margin calculation)
|
||||
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||
unitCost: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
margin: number;
|
||||
|
||||
// Status
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: LineStatus,
|
||||
default: LineStatus.ACTIVE,
|
||||
})
|
||||
status: LineStatus;
|
||||
|
||||
// Lot/Serial tracking
|
||||
@Column({ name: 'lot_number', length: 50, nullable: true })
|
||||
lotNumber: string;
|
||||
|
||||
@Column({ name: 'serial_number', length: 50, nullable: true })
|
||||
serialNumber: string;
|
||||
|
||||
@Column({ name: 'expiry_date', type: 'date', nullable: true })
|
||||
expiryDate: Date;
|
||||
|
||||
// Warehouse location
|
||||
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||
warehouseId: string;
|
||||
|
||||
@Column({ name: 'location_id', type: 'uuid', nullable: true })
|
||||
locationId: string;
|
||||
|
||||
// For returns/exchanges
|
||||
@Column({ name: 'original_line_id', type: 'uuid', nullable: true })
|
||||
originalLineId: string;
|
||||
|
||||
@Column({ name: 'return_reason', length: 255, nullable: true })
|
||||
returnReason: string;
|
||||
|
||||
// Salesperson override (if different from order)
|
||||
@Column({ name: 'salesperson_id', type: 'uuid', nullable: true })
|
||||
salespersonId: string;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => POSOrder, (order) => order.lines)
|
||||
@JoinColumn({ name: 'order_id' })
|
||||
order: POSOrder;
|
||||
}
|
||||
@ -1,223 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { POSSession } from './pos-session.entity';
|
||||
import { POSOrderLine } from './pos-order-line.entity';
|
||||
import { POSPayment } from './pos-payment.entity';
|
||||
|
||||
export enum OrderStatus {
|
||||
DRAFT = 'draft',
|
||||
CONFIRMED = 'confirmed',
|
||||
PAID = 'paid',
|
||||
PARTIALLY_PAID = 'partially_paid',
|
||||
VOIDED = 'voided',
|
||||
REFUNDED = 'refunded',
|
||||
}
|
||||
|
||||
export enum OrderType {
|
||||
SALE = 'sale',
|
||||
REFUND = 'refund',
|
||||
EXCHANGE = 'exchange',
|
||||
}
|
||||
|
||||
@Entity('pos_orders', { schema: 'retail' })
|
||||
@Index(['tenantId', 'branchId', 'createdAt'])
|
||||
@Index(['tenantId', 'sessionId'])
|
||||
@Index(['tenantId', 'customerId'])
|
||||
@Index(['tenantId', 'number'], { unique: true })
|
||||
export class POSOrder {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid' })
|
||||
branchId: string;
|
||||
|
||||
@Column({ name: 'session_id', type: 'uuid' })
|
||||
sessionId: string;
|
||||
|
||||
@Column({ name: 'register_id', type: 'uuid' })
|
||||
registerId: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({ length: 30, unique: true })
|
||||
number: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: OrderType,
|
||||
default: OrderType.SALE,
|
||||
})
|
||||
type: OrderType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: OrderStatus,
|
||||
default: OrderStatus.DRAFT,
|
||||
})
|
||||
status: OrderStatus;
|
||||
|
||||
// Customer (optional, from erp-core partners)
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId: string;
|
||||
|
||||
@Column({ name: 'customer_name', length: 200, nullable: true })
|
||||
customerName: string;
|
||||
|
||||
@Column({ name: 'customer_rfc', length: 13, nullable: true })
|
||||
customerRfc: string;
|
||||
|
||||
// Salesperson (for commission tracking)
|
||||
@Column({ name: 'salesperson_id', type: 'uuid', nullable: true })
|
||||
salespersonId: string;
|
||||
|
||||
// Amounts
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
subtotal: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ name: 'discount_reason', length: 255, nullable: true })
|
||||
discountReason: string;
|
||||
|
||||
@Column({ name: 'discount_authorized_by', type: 'uuid', nullable: true })
|
||||
discountAuthorizedBy: string;
|
||||
|
||||
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
taxAmount: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
total: number;
|
||||
|
||||
@Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
amountPaid: number;
|
||||
|
||||
@Column({ name: 'change_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
changeAmount: number;
|
||||
|
||||
// Tax breakdown
|
||||
@Column({ name: 'tax_breakdown', type: 'jsonb', nullable: true })
|
||||
taxBreakdown: {
|
||||
taxId: string;
|
||||
taxName: string;
|
||||
rate: number;
|
||||
base: number;
|
||||
amount: number;
|
||||
}[];
|
||||
|
||||
// Currency
|
||||
@Column({ name: 'currency_code', length: 3, default: 'MXN' })
|
||||
currencyCode: string;
|
||||
|
||||
@Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 })
|
||||
exchangeRate: number;
|
||||
|
||||
// Coupon/Promotion
|
||||
@Column({ name: 'coupon_id', type: 'uuid', nullable: true })
|
||||
couponId: string;
|
||||
|
||||
@Column({ name: 'coupon_code', length: 50, nullable: true })
|
||||
couponCode: string;
|
||||
|
||||
@Column({ name: 'promotion_ids', type: 'jsonb', nullable: true })
|
||||
promotionIds: string[];
|
||||
|
||||
// Loyalty points
|
||||
@Column({ name: 'loyalty_points_earned', type: 'int', default: 0 })
|
||||
loyaltyPointsEarned: number;
|
||||
|
||||
@Column({ name: 'loyalty_points_redeemed', type: 'int', default: 0 })
|
||||
loyaltyPointsRedeemed: number;
|
||||
|
||||
@Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
loyaltyPointsValue: number;
|
||||
|
||||
// Invoicing
|
||||
@Column({ name: 'requires_invoice', type: 'boolean', default: false })
|
||||
requiresInvoice: boolean;
|
||||
|
||||
@Column({ name: 'cfdi_id', type: 'uuid', nullable: true })
|
||||
cfdiId: string;
|
||||
|
||||
@Column({ name: 'invoice_status', length: 20, nullable: true })
|
||||
invoiceStatus: 'pending' | 'issued' | 'cancelled';
|
||||
|
||||
// Reference to original order (for refunds/exchanges)
|
||||
@Column({ name: 'original_order_id', type: 'uuid', nullable: true })
|
||||
originalOrderId: string;
|
||||
|
||||
// Void info
|
||||
@Column({ name: 'voided_at', type: 'timestamp with time zone', nullable: true })
|
||||
voidedAt: Date;
|
||||
|
||||
@Column({ name: 'voided_by', type: 'uuid', nullable: true })
|
||||
voidedBy: string;
|
||||
|
||||
@Column({ name: 'void_reason', length: 255, nullable: true })
|
||||
voidReason: string;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Offline sync
|
||||
@Column({ name: 'is_offline_order', type: 'boolean', default: false })
|
||||
isOfflineOrder: boolean;
|
||||
|
||||
@Column({ name: 'offline_uuid', type: 'uuid', nullable: true })
|
||||
offlineUuid: string;
|
||||
|
||||
@Column({ name: 'synced_at', type: 'timestamp with time zone', nullable: true })
|
||||
syncedAt: Date;
|
||||
|
||||
// Receipt
|
||||
@Column({ name: 'receipt_printed', type: 'boolean', default: false })
|
||||
receiptPrinted: boolean;
|
||||
|
||||
@Column({ name: 'receipt_sent', type: 'boolean', default: false })
|
||||
receiptSent: boolean;
|
||||
|
||||
@Column({ name: 'receipt_email', length: 100, nullable: true })
|
||||
receiptEmail: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@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[];
|
||||
}
|
||||
@ -1,173 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { POSOrder } from './pos-order.entity';
|
||||
|
||||
export enum PaymentMethod {
|
||||
CASH = 'cash',
|
||||
DEBIT_CARD = 'debit_card',
|
||||
CREDIT_CARD = 'credit_card',
|
||||
TRANSFER = 'transfer',
|
||||
CHECK = 'check',
|
||||
LOYALTY_POINTS = 'loyalty_points',
|
||||
VOUCHER = 'voucher',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export enum PaymentStatus {
|
||||
PENDING = 'pending',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
REFUNDED = 'refunded',
|
||||
VOIDED = 'voided',
|
||||
}
|
||||
|
||||
@Entity('pos_payments', { schema: 'retail' })
|
||||
@Index(['tenantId', 'orderId'])
|
||||
@Index(['tenantId', 'sessionId'])
|
||||
@Index(['tenantId', 'createdAt'])
|
||||
export class POSPayment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'order_id', type: 'uuid' })
|
||||
orderId: string;
|
||||
|
||||
@Column({ name: 'session_id', type: 'uuid' })
|
||||
sessionId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PaymentMethod,
|
||||
})
|
||||
method: PaymentMethod;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PaymentStatus,
|
||||
default: PaymentStatus.PENDING,
|
||||
})
|
||||
status: PaymentStatus;
|
||||
|
||||
// Amount
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
@Column({ name: 'amount_received', type: 'decimal', precision: 15, scale: 2 })
|
||||
amountReceived: number;
|
||||
|
||||
@Column({ name: 'change_given', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
changeGiven: number;
|
||||
|
||||
// Currency
|
||||
@Column({ name: 'currency_code', length: 3, default: 'MXN' })
|
||||
currencyCode: string;
|
||||
|
||||
@Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 })
|
||||
exchangeRate: number;
|
||||
|
||||
// Card payment details
|
||||
@Column({ name: 'card_type', length: 20, nullable: true })
|
||||
cardType: string; // visa, mastercard, amex, etc.
|
||||
|
||||
@Column({ name: 'card_last_four', length: 4, nullable: true })
|
||||
cardLastFour: string;
|
||||
|
||||
@Column({ name: 'card_holder', length: 100, nullable: true })
|
||||
cardHolder: string;
|
||||
|
||||
@Column({ name: 'authorization_code', length: 50, nullable: true })
|
||||
authorizationCode: string;
|
||||
|
||||
@Column({ name: 'transaction_reference', length: 100, nullable: true })
|
||||
transactionReference: string;
|
||||
|
||||
@Column({ name: 'terminal_id', length: 50, nullable: true })
|
||||
terminalId: string;
|
||||
|
||||
// Transfer details
|
||||
@Column({ name: 'bank_name', length: 100, nullable: true })
|
||||
bankName: string;
|
||||
|
||||
@Column({ name: 'transfer_reference', length: 100, nullable: true })
|
||||
transferReference: string;
|
||||
|
||||
// Check details
|
||||
@Column({ name: 'check_number', length: 50, nullable: true })
|
||||
checkNumber: string;
|
||||
|
||||
@Column({ name: 'check_bank', length: 100, nullable: true })
|
||||
checkBank: string;
|
||||
|
||||
@Column({ name: 'check_date', type: 'date', nullable: true })
|
||||
checkDate: Date;
|
||||
|
||||
// Voucher/Gift card
|
||||
@Column({ name: 'voucher_code', length: 50, nullable: true })
|
||||
voucherCode: string;
|
||||
|
||||
@Column({ name: 'voucher_id', type: 'uuid', nullable: true })
|
||||
voucherId: string;
|
||||
|
||||
// Loyalty points
|
||||
@Column({ name: 'loyalty_points_used', type: 'int', nullable: true })
|
||||
loyaltyPointsUsed: number;
|
||||
|
||||
// Tips
|
||||
@Column({ name: 'tip_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
tipAmount: number;
|
||||
|
||||
// Financial account (for accounting integration)
|
||||
@Column({ name: 'account_id', type: 'uuid', nullable: true })
|
||||
accountId: string;
|
||||
|
||||
// Refund reference
|
||||
@Column({ name: 'refund_of_payment_id', type: 'uuid', nullable: true })
|
||||
refundOfPaymentId: string;
|
||||
|
||||
@Column({ name: 'refund_reason', length: 255, nullable: true })
|
||||
refundReason: string;
|
||||
|
||||
// Processing info
|
||||
@Column({ name: 'processed_at', type: 'timestamp with time zone', nullable: true })
|
||||
processedAt: Date;
|
||||
|
||||
@Column({ name: 'processed_by', type: 'uuid', nullable: true })
|
||||
processedBy: string;
|
||||
|
||||
// External payment gateway
|
||||
@Column({ name: 'gateway_name', length: 50, nullable: true })
|
||||
gatewayName: string;
|
||||
|
||||
@Column({ name: 'gateway_transaction_id', length: 100, nullable: true })
|
||||
gatewayTransactionId: string;
|
||||
|
||||
@Column({ name: 'gateway_response', type: 'jsonb', nullable: true })
|
||||
gatewayResponse: Record<string, any>;
|
||||
|
||||
// Notes
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => POSOrder, (order) => order.payments)
|
||||
@JoinColumn({ name: 'order_id' })
|
||||
order: POSOrder;
|
||||
}
|
||||
@ -1,146 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { POSOrder } from './pos-order.entity';
|
||||
|
||||
export enum SessionStatus {
|
||||
OPEN = 'open',
|
||||
CLOSING = 'closing',
|
||||
CLOSED = 'closed',
|
||||
RECONCILED = 'reconciled',
|
||||
}
|
||||
|
||||
@Entity('pos_sessions', { schema: 'retail' })
|
||||
@Index(['tenantId', 'branchId', 'status'])
|
||||
@Index(['tenantId', 'registerId', 'status'])
|
||||
@Index(['tenantId', 'openedAt'])
|
||||
export class POSSession {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid' })
|
||||
branchId: string;
|
||||
|
||||
@Column({ name: 'register_id', type: 'uuid' })
|
||||
registerId: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({ length: 30, unique: true })
|
||||
number: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: SessionStatus,
|
||||
default: SessionStatus.OPEN,
|
||||
})
|
||||
status: SessionStatus;
|
||||
|
||||
// Opening
|
||||
@Column({ name: 'opened_at', type: 'timestamp with time zone' })
|
||||
openedAt: Date;
|
||||
|
||||
@Column({ name: 'opening_cash', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
openingCash: number;
|
||||
|
||||
@Column({ name: 'opening_notes', type: 'text', nullable: true })
|
||||
openingNotes: string;
|
||||
|
||||
// Closing
|
||||
@Column({ name: 'closed_at', type: 'timestamp with time zone', nullable: true })
|
||||
closedAt: Date;
|
||||
|
||||
@Column({ name: 'closing_cash_counted', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
closingCashCounted: number;
|
||||
|
||||
@Column({ name: 'closing_cash_expected', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
closingCashExpected: number;
|
||||
|
||||
@Column({ name: 'closing_difference', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
closingDifference: number;
|
||||
|
||||
@Column({ name: 'closing_notes', type: 'text', nullable: true })
|
||||
closingNotes: string;
|
||||
|
||||
@Column({ name: 'closed_by', type: 'uuid', nullable: true })
|
||||
closedBy: string;
|
||||
|
||||
// Session totals (denormalized for performance)
|
||||
@Column({ name: 'total_sales', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalSales: number;
|
||||
|
||||
@Column({ name: 'total_refunds', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalRefunds: number;
|
||||
|
||||
@Column({ name: 'total_discounts', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalDiscounts: number;
|
||||
|
||||
@Column({ name: 'orders_count', type: 'int', default: 0 })
|
||||
ordersCount: number;
|
||||
|
||||
@Column({ name: 'items_sold', type: 'int', default: 0 })
|
||||
itemsSold: number;
|
||||
|
||||
// Payment totals by method
|
||||
@Column({ name: 'cash_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
cashTotal: number;
|
||||
|
||||
@Column({ name: 'card_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
cardTotal: number;
|
||||
|
||||
@Column({ name: 'transfer_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
transferTotal: number;
|
||||
|
||||
@Column({ name: 'other_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
otherTotal: number;
|
||||
|
||||
// Cash movements during session
|
||||
@Column({ name: 'cash_in_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
cashInTotal: number;
|
||||
|
||||
@Column({ name: 'cash_out_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
cashOutTotal: number;
|
||||
|
||||
// Detailed cash count (closing)
|
||||
@Column({ name: 'cash_count_detail', type: 'jsonb', nullable: true })
|
||||
cashCountDetail: {
|
||||
bills: { denomination: number; count: number; total: number }[];
|
||||
coins: { denomination: number; count: number; total: number }[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
// Offline sync
|
||||
@Column({ name: 'is_offline_session', type: 'boolean', default: false })
|
||||
isOfflineSession: boolean;
|
||||
|
||||
@Column({ name: 'synced_at', type: 'timestamp with time zone', nullable: true })
|
||||
syncedAt: Date;
|
||||
|
||||
@Column({ name: 'device_uuid', type: 'uuid', nullable: true })
|
||||
deviceUuid: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => POSOrder, (order) => order.session)
|
||||
orders: POSOrder[];
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
export { default as posRoutes } from './routes/pos.routes';
|
||||
@ -1,143 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { posController } from '../controllers/pos.controller';
|
||||
import { authMiddleware, requireRoles } from '../../../shared/middleware/auth.middleware';
|
||||
import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware';
|
||||
import { branchMiddleware, validateBranchMiddleware, requireBranchCapability } from '../../../shared/middleware/branch.middleware';
|
||||
import { AuthenticatedRequest } from '../../../shared/types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require tenant and authentication
|
||||
router.use(tenantMiddleware);
|
||||
router.use(authMiddleware);
|
||||
|
||||
// ==================== SESSION ROUTES ====================
|
||||
|
||||
// Get active session for current user (no branch required)
|
||||
router.get('/sessions/active', (req, res, next) =>
|
||||
posController.getActiveSession(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Routes that require branch context
|
||||
router.use('/sessions', branchMiddleware);
|
||||
router.use('/sessions', validateBranchMiddleware);
|
||||
router.use('/sessions', requireBranchCapability('pos'));
|
||||
|
||||
// Open session
|
||||
router.post('/sessions/open', (req, res, next) =>
|
||||
posController.openSession(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Close session
|
||||
router.post('/sessions/:id/close', (req, res, next) =>
|
||||
posController.closeSession(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Get daily summary
|
||||
router.get('/sessions/daily-summary', (req, res, next) =>
|
||||
posController.getDailySummary(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// List sessions
|
||||
router.get('/sessions', (req, res, next) =>
|
||||
posController.listSessions(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Get session by ID
|
||||
router.get('/sessions/:id', (req, res, next) =>
|
||||
posController.getSession(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// ==================== ORDER ROUTES ====================
|
||||
|
||||
// Routes that require branch context
|
||||
router.use('/orders', branchMiddleware);
|
||||
router.use('/orders', validateBranchMiddleware);
|
||||
router.use('/orders', requireBranchCapability('pos'));
|
||||
|
||||
// Create order
|
||||
router.post('/orders', (req, res, next) =>
|
||||
posController.createOrder(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// List orders
|
||||
router.get('/orders', (req, res, next) =>
|
||||
posController.listOrders(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Get orders by session
|
||||
router.get('/orders/session/:sessionId', (req, res, next) =>
|
||||
posController.getOrdersBySession(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Get order by ID
|
||||
router.get('/orders/:id', (req, res, next) =>
|
||||
posController.getOrder(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Add payment
|
||||
router.post('/orders/:id/payments', (req, res, next) =>
|
||||
posController.addPayment(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Void order (requires supervisor or higher)
|
||||
router.post(
|
||||
'/orders/:id/void',
|
||||
requireRoles('admin', 'manager', 'supervisor'),
|
||||
(req, res, next) => posController.voidOrder(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Hold order
|
||||
router.post('/orders/:id/hold', (req, res, next) =>
|
||||
posController.holdOrder(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Get held orders
|
||||
router.get('/orders/held', (req, res, next) =>
|
||||
posController.getHeldOrders(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Recall held order
|
||||
router.post('/orders/:id/recall', (req, res, next) =>
|
||||
posController.recallOrder(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Apply coupon to order
|
||||
router.post('/orders/:id/coupon', (req, res, next) =>
|
||||
posController.applyCoupon(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Mark receipt as printed
|
||||
router.post('/orders/:id/receipt/print', (req, res, next) =>
|
||||
posController.markReceiptPrinted(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Send receipt by email
|
||||
router.post('/orders/:id/receipt/send', (req, res, next) =>
|
||||
posController.sendReceipt(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// ==================== REFUND ROUTES ====================
|
||||
|
||||
// Create refund
|
||||
router.post(
|
||||
'/refunds',
|
||||
requireRoles('admin', 'manager', 'supervisor'),
|
||||
(req, res, next) => posController.createRefund(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// Process refund payment
|
||||
router.post(
|
||||
'/refunds/:id/process',
|
||||
requireRoles('admin', 'manager', 'supervisor'),
|
||||
(req, res, next) => posController.processRefund(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
// ==================== SESSION STATS ====================
|
||||
|
||||
// Get session statistics
|
||||
router.get('/sessions/:id/stats', (req, res, next) =>
|
||||
posController.getSessionStats(req as AuthenticatedRequest, res, next)
|
||||
);
|
||||
|
||||
export default router;
|
||||
@ -1,2 +0,0 @@
|
||||
export * from './pos-session.service';
|
||||
export * from './pos-order.service';
|
||||
@ -1,914 +0,0 @@
|
||||
import { Repository, DeepPartial } from 'typeorm';
|
||||
import { AppDataSource } from '../../../config/typeorm';
|
||||
import { BaseService } from '../../../shared/services/base.service';
|
||||
import { ServiceResult } from '../../../shared/types';
|
||||
import { POSOrder, OrderStatus, OrderType } from '../entities/pos-order.entity';
|
||||
import { POSOrderLine, LineStatus } from '../entities/pos-order-line.entity';
|
||||
import { POSPayment, PaymentMethod, PaymentStatus } from '../entities/pos-payment.entity';
|
||||
import { posSessionService } from './pos-session.service';
|
||||
|
||||
interface CreateOrderDTO {
|
||||
sessionId: string;
|
||||
branchId: string;
|
||||
registerId: string;
|
||||
userId: string;
|
||||
customerId?: string;
|
||||
customerName?: string;
|
||||
customerRfc?: string;
|
||||
lines: {
|
||||
productId: string;
|
||||
productCode: string;
|
||||
productName: string;
|
||||
productBarcode?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
originalPrice: number;
|
||||
discountPercent?: number;
|
||||
discountAmount?: number;
|
||||
taxRate: number;
|
||||
taxIncluded?: boolean;
|
||||
}[];
|
||||
discountPercent?: number;
|
||||
discountAmount?: number;
|
||||
discountReason?: string;
|
||||
requiresInvoice?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface AddPaymentDTO {
|
||||
method: PaymentMethod;
|
||||
amount: number;
|
||||
amountReceived: number;
|
||||
cardType?: string;
|
||||
cardLastFour?: string;
|
||||
authorizationCode?: string;
|
||||
transactionReference?: string;
|
||||
}
|
||||
|
||||
export class POSOrderService extends BaseService<POSOrder> {
|
||||
private lineRepository: Repository<POSOrderLine>;
|
||||
private paymentRepository: Repository<POSPayment>;
|
||||
|
||||
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<ServiceResult<POSOrder>> {
|
||||
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<POSOrderLine>[] = 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<ServiceResult<POSOrder>> {
|
||||
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<ServiceResult<POSOrder>> {
|
||||
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<POSOrder | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id: orderId, tenantId },
|
||||
relations: ['lines', 'payments'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders by session
|
||||
*/
|
||||
async getOrdersBySession(tenantId: string, sessionId: string): Promise<POSOrder[]> {
|
||||
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<POSOrder[]> {
|
||||
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<ServiceResult<POSOrder>> {
|
||||
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<POSOrderLine>[] = [];
|
||||
|
||||
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<ServiceResult<POSOrder>> {
|
||||
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<ServiceResult<POSOrder>> {
|
||||
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<POSOrder[]> {
|
||||
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<ServiceResult<POSOrder>> {
|
||||
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<ServiceResult<POSOrder>> {
|
||||
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<void> {
|
||||
await this.repository.update(
|
||||
{ id: orderId, tenantId },
|
||||
{ receiptPrinted: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send receipt by email
|
||||
*/
|
||||
async sendReceiptEmail(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
email: string
|
||||
): Promise<ServiceResult<void>> {
|
||||
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<string, number>;
|
||||
}> {
|
||||
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<string, number> = {};
|
||||
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();
|
||||
@ -1,360 +0,0 @@
|
||||
import { Repository, DeepPartial } from 'typeorm';
|
||||
import { AppDataSource } from '../../../config/typeorm';
|
||||
import { BaseService } from '../../../shared/services/base.service';
|
||||
import { ServiceResult } from '../../../shared/types';
|
||||
import { POSSession, SessionStatus } from '../entities/pos-session.entity';
|
||||
import { CashRegister, RegisterStatus } from '../../branches/entities/cash-register.entity';
|
||||
|
||||
export class POSSessionService extends BaseService<POSSession> {
|
||||
private registerRepository: Repository<CashRegister>;
|
||||
|
||||
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<ServiceResult<POSSession>> {
|
||||
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<ServiceResult<POSSession>> {
|
||||
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<POSSession | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
tenantId,
|
||||
userId,
|
||||
status: SessionStatus.OPEN,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active session for a register
|
||||
*/
|
||||
async getRegisterSession(tenantId: string, registerId: string): Promise<POSSession | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
tenantId,
|
||||
registerId,
|
||||
status: SessionStatus.OPEN,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session with orders
|
||||
*/
|
||||
async getSessionWithOrders(tenantId: string, sessionId: string): Promise<POSSession | null> {
|
||||
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<void> {
|
||||
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<POSSession[]> {
|
||||
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();
|
||||
@ -1,206 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
uuidSchema,
|
||||
moneySchema,
|
||||
quantitySchema,
|
||||
percentSchema,
|
||||
paginationSchema,
|
||||
rfcSchema,
|
||||
emailSchema,
|
||||
} from '../../../shared/validation/common.schema';
|
||||
|
||||
// Enums
|
||||
export const paymentMethodEnum = z.enum([
|
||||
'cash',
|
||||
'debit_card',
|
||||
'credit_card',
|
||||
'transfer',
|
||||
'check',
|
||||
'loyalty_points',
|
||||
'voucher',
|
||||
'other',
|
||||
]);
|
||||
|
||||
export const orderStatusEnum = z.enum([
|
||||
'draft',
|
||||
'confirmed',
|
||||
'paid',
|
||||
'partially_paid',
|
||||
'voided',
|
||||
'refunded',
|
||||
]);
|
||||
|
||||
// ==================== SESSION SCHEMAS ====================
|
||||
|
||||
// Open session schema
|
||||
export const openSessionSchema = z.object({
|
||||
registerId: uuidSchema,
|
||||
openingCash: moneySchema.default(0),
|
||||
openingNotes: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
// Close session schema
|
||||
export const closeSessionSchema = z.object({
|
||||
closingCashCounted: moneySchema,
|
||||
closingNotes: z.string().max(500).optional(),
|
||||
cashCountDetail: z.object({
|
||||
bills: z.array(z.object({
|
||||
denomination: z.number(),
|
||||
count: z.number().int().min(0),
|
||||
total: z.number(),
|
||||
})).optional(),
|
||||
coins: z.array(z.object({
|
||||
denomination: z.number(),
|
||||
count: z.number().int().min(0),
|
||||
total: z.number(),
|
||||
})).optional(),
|
||||
total: z.number(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// List sessions query schema
|
||||
export const listSessionsQuerySchema = paginationSchema.extend({
|
||||
status: z.enum(['open', 'closing', 'closed', 'reconciled']).optional(),
|
||||
userId: uuidSchema.optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
// ==================== ORDER SCHEMAS ====================
|
||||
|
||||
// Order line schema
|
||||
export const orderLineSchema = z.object({
|
||||
productId: uuidSchema,
|
||||
productCode: z.string().min(1).max(50),
|
||||
productName: z.string().min(1).max(200),
|
||||
productBarcode: z.string().max(50).optional(),
|
||||
variantId: uuidSchema.optional(),
|
||||
variantName: z.string().max(200).optional(),
|
||||
quantity: quantitySchema.min(0.0001),
|
||||
unitPrice: moneySchema,
|
||||
originalPrice: moneySchema,
|
||||
discountPercent: percentSchema.optional(),
|
||||
discountAmount: moneySchema.optional(),
|
||||
taxRate: z.coerce.number().min(0).max(1).default(0.16),
|
||||
taxIncluded: z.boolean().default(true),
|
||||
notes: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
// Create order schema
|
||||
export const createOrderSchema = z.object({
|
||||
sessionId: uuidSchema,
|
||||
registerId: uuidSchema,
|
||||
customerId: uuidSchema.optional(),
|
||||
customerName: z.string().max(200).optional(),
|
||||
customerRfc: rfcSchema.optional(),
|
||||
salespersonId: uuidSchema.optional(),
|
||||
lines: z.array(orderLineSchema).min(1, 'At least one line is required'),
|
||||
discountPercent: percentSchema.optional(),
|
||||
discountAmount: moneySchema.optional(),
|
||||
discountReason: z.string().max(255).optional(),
|
||||
couponId: uuidSchema.optional(),
|
||||
couponCode: z.string().max(50).optional(),
|
||||
requiresInvoice: z.boolean().default(false),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
// Add payment schema
|
||||
export const addPaymentSchema = z.object({
|
||||
method: paymentMethodEnum,
|
||||
amount: moneySchema.positive('Amount must be positive'),
|
||||
amountReceived: moneySchema.optional(),
|
||||
cardType: z.string().max(20).optional(),
|
||||
cardLastFour: z.string().length(4).optional(),
|
||||
cardHolder: z.string().max(100).optional(),
|
||||
authorizationCode: z.string().max(50).optional(),
|
||||
transactionReference: z.string().max(100).optional(),
|
||||
terminalId: z.string().max(50).optional(),
|
||||
bankName: z.string().max(100).optional(),
|
||||
transferReference: z.string().max(100).optional(),
|
||||
checkNumber: z.string().max(50).optional(),
|
||||
checkBank: z.string().max(100).optional(),
|
||||
voucherCode: z.string().max(50).optional(),
|
||||
loyaltyPointsUsed: z.number().int().min(0).optional(),
|
||||
tipAmount: moneySchema.optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
// Void order schema
|
||||
export const voidOrderSchema = z.object({
|
||||
reason: z.string().min(1, 'Reason is required').max(255),
|
||||
});
|
||||
|
||||
// List orders query schema
|
||||
export const listOrdersQuerySchema = paginationSchema.extend({
|
||||
sessionId: uuidSchema.optional(),
|
||||
status: orderStatusEnum.optional(),
|
||||
customerId: uuidSchema.optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
// ==================== RECEIPT SCHEMAS ====================
|
||||
|
||||
// Send receipt schema
|
||||
export const sendReceiptSchema = z.object({
|
||||
email: emailSchema,
|
||||
});
|
||||
|
||||
// ==================== REFUND SCHEMAS ====================
|
||||
|
||||
// Refund line schema
|
||||
export const refundLineSchema = z.object({
|
||||
originalLineId: uuidSchema,
|
||||
quantity: quantitySchema.positive('Quantity must be positive'),
|
||||
reason: z.string().min(1).max(255),
|
||||
});
|
||||
|
||||
// Create refund schema
|
||||
export const createRefundSchema = z.object({
|
||||
originalOrderId: uuidSchema,
|
||||
sessionId: uuidSchema,
|
||||
registerId: uuidSchema,
|
||||
lines: z.array(refundLineSchema).min(1, 'At least one line is required'),
|
||||
refundReason: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
// Process refund payment schema
|
||||
export const processRefundPaymentSchema = z.object({
|
||||
method: paymentMethodEnum,
|
||||
amount: moneySchema.positive('Amount must be positive'),
|
||||
});
|
||||
|
||||
// ==================== HOLD/RECALL SCHEMAS ====================
|
||||
|
||||
// Hold order schema
|
||||
export const holdOrderSchema = z.object({
|
||||
holdName: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
// Recall order schema
|
||||
export const recallOrderSchema = z.object({
|
||||
sessionId: uuidSchema,
|
||||
registerId: uuidSchema,
|
||||
});
|
||||
|
||||
// ==================== COUPON SCHEMA ====================
|
||||
|
||||
// Apply coupon schema
|
||||
export const applyCouponSchema = z.object({
|
||||
couponCode: z.string().min(1).max(50),
|
||||
});
|
||||
|
||||
// Types
|
||||
export type OpenSessionInput = z.infer<typeof openSessionSchema>;
|
||||
export type CloseSessionInput = z.infer<typeof closeSessionSchema>;
|
||||
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
|
||||
export type AddPaymentInput = z.infer<typeof addPaymentSchema>;
|
||||
export type VoidOrderInput = z.infer<typeof voidOrderSchema>;
|
||||
export type OrderLineInput = z.infer<typeof orderLineSchema>;
|
||||
export type RefundLineInput = z.infer<typeof refundLineSchema>;
|
||||
export type CreateRefundInput = z.infer<typeof createRefundSchema>;
|
||||
export type ProcessRefundPaymentInput = z.infer<typeof processRefundPaymentSchema>;
|
||||
export type HoldOrderInput = z.infer<typeof holdOrderSchema>;
|
||||
export type RecallOrderInput = z.infer<typeof recallOrderSchema>;
|
||||
export type ApplyCouponInput = z.infer<typeof applyCouponSchema>;
|
||||
@ -1 +0,0 @@
|
||||
export * from './pricing.controller';
|
||||
@ -1,985 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { PriceEngineService } from '../services/price-engine.service';
|
||||
import { PromotionService } from '../services/promotion.service';
|
||||
import { CouponService } from '../services/coupon.service';
|
||||
import {
|
||||
CreatePromotionInput,
|
||||
UpdatePromotionInput,
|
||||
CreateCouponInput,
|
||||
UpdateCouponInput,
|
||||
CalculatePricesInput,
|
||||
RedeemCouponInput,
|
||||
ListPromotionsQuery,
|
||||
ListCouponsQuery,
|
||||
PromotionProductInput,
|
||||
GenerateBulkCouponsInput,
|
||||
} from '../validation/pricing.schema';
|
||||
import { AppDataSource } from '../../../config/database';
|
||||
import { Promotion } from '../entities/promotion.entity';
|
||||
import { Coupon } from '../entities/coupon.entity';
|
||||
|
||||
const priceEngineService = new PriceEngineService(AppDataSource);
|
||||
const promotionService = new PromotionService(
|
||||
AppDataSource,
|
||||
AppDataSource.getRepository(Promotion)
|
||||
);
|
||||
const couponService = new CouponService(
|
||||
AppDataSource,
|
||||
AppDataSource.getRepository(Coupon)
|
||||
);
|
||||
|
||||
// ==================== PRICE ENGINE ====================
|
||||
|
||||
/**
|
||||
* Calculate prices for cart/order items
|
||||
* POST /api/pricing/calculate
|
||||
*/
|
||||
export const calculatePrices = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@ -1,116 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Coupon } from './coupon.entity';
|
||||
|
||||
export enum RedemptionStatus {
|
||||
APPLIED = 'applied',
|
||||
COMPLETED = 'completed',
|
||||
REVERSED = 'reversed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
@Entity('coupon_redemptions', { schema: 'retail' })
|
||||
@Index(['tenantId', 'couponId', 'createdAt'])
|
||||
@Index(['tenantId', 'customerId'])
|
||||
@Index(['tenantId', 'orderId'])
|
||||
export class CouponRedemption {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'coupon_id', type: 'uuid' })
|
||||
couponId: string;
|
||||
|
||||
@Column({ name: 'coupon_code', length: 50 })
|
||||
couponCode: string;
|
||||
|
||||
@Column({ name: 'order_id', type: 'uuid' })
|
||||
orderId: string;
|
||||
|
||||
@Column({ name: 'order_number', length: 30 })
|
||||
orderNumber: string;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId: string;
|
||||
|
||||
@Column({ name: 'branch_id', type: 'uuid' })
|
||||
branchId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: RedemptionStatus,
|
||||
default: RedemptionStatus.APPLIED,
|
||||
})
|
||||
status: RedemptionStatus;
|
||||
|
||||
// Amounts
|
||||
@Column({ name: 'order_amount', type: 'decimal', precision: 15, scale: 2 })
|
||||
orderAmount: number;
|
||||
|
||||
@Column({ name: 'discount_applied', type: 'decimal', precision: 15, scale: 2 })
|
||||
discountApplied: number;
|
||||
|
||||
// Discount breakdown
|
||||
@Column({ name: 'discount_type', length: 30 })
|
||||
discountType: string;
|
||||
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
discountAmount: number;
|
||||
|
||||
// Free product info
|
||||
@Column({ name: 'free_product_id', type: 'uuid', nullable: true })
|
||||
freeProductId: string;
|
||||
|
||||
@Column({ name: 'free_product_quantity', type: 'int', nullable: true })
|
||||
freeProductQuantity: number;
|
||||
|
||||
@Column({ name: 'free_product_value', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
freeProductValue: number;
|
||||
|
||||
// Channel
|
||||
@Column({ length: 20, default: 'pos' })
|
||||
channel: 'pos' | 'ecommerce' | 'mobile';
|
||||
|
||||
// Reversal info
|
||||
@Column({ name: 'reversed_at', type: 'timestamp with time zone', nullable: true })
|
||||
reversedAt: Date;
|
||||
|
||||
@Column({ name: 'reversed_by', type: 'uuid', nullable: true })
|
||||
reversedBy: string;
|
||||
|
||||
@Column({ name: 'reversal_reason', length: 255, nullable: true })
|
||||
reversalReason: string;
|
||||
|
||||
// User who applied the coupon
|
||||
@Column({ name: 'applied_by', type: 'uuid' })
|
||||
appliedBy: string;
|
||||
|
||||
// IP address (for e-commerce fraud prevention)
|
||||
@Column({ name: 'ip_address', length: 45, nullable: true })
|
||||
ipAddress: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Coupon, (coupon) => coupon.redemptions)
|
||||
@JoinColumn({ name: 'coupon_id' })
|
||||
coupon: Coupon;
|
||||
}
|
||||
@ -1,195 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { CouponRedemption } from './coupon-redemption.entity';
|
||||
|
||||
export enum CouponStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
EXPIRED = 'expired',
|
||||
DEPLETED = 'depleted',
|
||||
}
|
||||
|
||||
export enum CouponType {
|
||||
PERCENTAGE = 'percentage',
|
||||
FIXED_AMOUNT = 'fixed_amount',
|
||||
FREE_SHIPPING = 'free_shipping',
|
||||
FREE_PRODUCT = 'free_product',
|
||||
}
|
||||
|
||||
export enum CouponScope {
|
||||
ORDER = 'order',
|
||||
PRODUCT = 'product',
|
||||
CATEGORY = 'category',
|
||||
SHIPPING = 'shipping',
|
||||
}
|
||||
|
||||
@Entity('coupons', { schema: 'retail' })
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
@Index(['tenantId', 'status', 'validFrom', 'validUntil'])
|
||||
@Index(['tenantId', 'campaignId'])
|
||||
export class Coupon {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 50, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CouponType,
|
||||
})
|
||||
type: CouponType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CouponScope,
|
||||
default: CouponScope.ORDER,
|
||||
})
|
||||
scope: CouponScope;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CouponStatus,
|
||||
default: CouponStatus.ACTIVE,
|
||||
})
|
||||
status: CouponStatus;
|
||||
|
||||
// Discount value
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'max_discount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
maxDiscount: number;
|
||||
|
||||
// Free product
|
||||
@Column({ name: 'free_product_id', type: 'uuid', nullable: true })
|
||||
freeProductId: string;
|
||||
|
||||
@Column({ name: 'free_product_quantity', type: 'int', default: 1 })
|
||||
freeProductQuantity: number;
|
||||
|
||||
// Validity
|
||||
@Column({ name: 'valid_from', type: 'timestamp with time zone' })
|
||||
validFrom: Date;
|
||||
|
||||
@Column({ name: 'valid_until', type: 'timestamp with time zone', nullable: true })
|
||||
validUntil: Date;
|
||||
|
||||
// Usage limits
|
||||
@Column({ name: 'max_uses', type: 'int', nullable: true })
|
||||
maxUses: number;
|
||||
|
||||
@Column({ name: 'max_uses_per_customer', type: 'int', default: 1 })
|
||||
maxUsesPerCustomer: number;
|
||||
|
||||
@Column({ name: 'current_uses', type: 'int', default: 0 })
|
||||
currentUses: number;
|
||||
|
||||
// Minimum requirements
|
||||
@Column({ name: 'min_order_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
minOrderAmount: number;
|
||||
|
||||
@Column({ name: 'min_items', type: 'int', nullable: true })
|
||||
minItems: number;
|
||||
|
||||
// Scope restrictions
|
||||
@Column({ name: 'applies_to_categories', type: 'jsonb', nullable: true })
|
||||
appliesToCategories: string[];
|
||||
|
||||
@Column({ name: 'applies_to_products', type: 'jsonb', nullable: true })
|
||||
appliesToProducts: string[];
|
||||
|
||||
@Column({ name: 'excluded_products', type: 'jsonb', nullable: true })
|
||||
excludedProducts: string[];
|
||||
|
||||
@Column({ name: 'included_branches', type: 'jsonb', nullable: true })
|
||||
includedBranches: string[];
|
||||
|
||||
// Customer restrictions
|
||||
@Column({ name: 'customer_specific', type: 'boolean', default: false })
|
||||
customerSpecific: boolean;
|
||||
|
||||
@Column({ name: 'allowed_customers', type: 'jsonb', nullable: true })
|
||||
allowedCustomers: string[];
|
||||
|
||||
@Column({ name: 'customer_levels', type: 'jsonb', nullable: true })
|
||||
customerLevels: string[];
|
||||
|
||||
@Column({ name: 'new_customers_only', type: 'boolean', default: false })
|
||||
newCustomersOnly: boolean;
|
||||
|
||||
@Column({ name: 'first_order_only', type: 'boolean', default: false })
|
||||
firstOrderOnly: boolean;
|
||||
|
||||
// Campaign
|
||||
@Column({ name: 'campaign_id', type: 'uuid', nullable: true })
|
||||
campaignId: string;
|
||||
|
||||
@Column({ name: 'campaign_name', length: 100, nullable: true })
|
||||
campaignName: string;
|
||||
|
||||
// Stackability
|
||||
@Column({ name: 'stackable_with_promotions', type: 'boolean', default: false })
|
||||
stackableWithPromotions: boolean;
|
||||
|
||||
@Column({ name: 'stackable_with_coupons', type: 'boolean', default: false })
|
||||
stackableWithCoupons: boolean;
|
||||
|
||||
// Channels
|
||||
@Column({ name: 'pos_enabled', type: 'boolean', default: true })
|
||||
posEnabled: boolean;
|
||||
|
||||
@Column({ name: 'ecommerce_enabled', type: 'boolean', default: true })
|
||||
ecommerceEnabled: boolean;
|
||||
|
||||
// Distribution
|
||||
@Column({ name: 'is_single_use', type: 'boolean', default: false })
|
||||
isSingleUse: boolean;
|
||||
|
||||
@Column({ name: 'auto_apply', type: 'boolean', default: false })
|
||||
autoApply: boolean;
|
||||
|
||||
@Column({ name: 'public_visible', type: 'boolean', default: false })
|
||||
publicVisible: boolean;
|
||||
|
||||
// Terms
|
||||
@Column({ name: 'terms_and_conditions', type: 'text', nullable: true })
|
||||
termsAndConditions: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@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[];
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export * from './promotion.entity';
|
||||
export * from './promotion-product.entity';
|
||||
export * from './coupon.entity';
|
||||
export * from './coupon-redemption.entity';
|
||||
@ -1,91 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Promotion } from './promotion.entity';
|
||||
|
||||
export enum ProductRole {
|
||||
TRIGGER = 'trigger', // Must be purchased to activate promotion
|
||||
TARGET = 'target', // Receives the discount/benefit
|
||||
BOTH = 'both', // Triggers and receives discount
|
||||
GIFT = 'gift', // Free gift product
|
||||
}
|
||||
|
||||
@Entity('promotion_products', { schema: 'retail' })
|
||||
@Index(['tenantId', 'promotionId'])
|
||||
@Index(['tenantId', 'productId'])
|
||||
@Index(['tenantId', 'promotionId', 'productId'], { unique: true })
|
||||
export class PromotionProduct {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'promotion_id', type: 'uuid' })
|
||||
promotionId: string;
|
||||
|
||||
// Product (from erp-core)
|
||||
@Column({ name: 'product_id', type: 'uuid' })
|
||||
productId: string;
|
||||
|
||||
@Column({ name: 'product_code', length: 50 })
|
||||
productCode: string;
|
||||
|
||||
@Column({ name: 'product_name', length: 200 })
|
||||
productName: string;
|
||||
|
||||
// Variant (optional)
|
||||
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
|
||||
variantId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ProductRole,
|
||||
default: ProductRole.BOTH,
|
||||
})
|
||||
role: ProductRole;
|
||||
|
||||
// Specific discount for this product (overrides promotion default)
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'fixed_price', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
fixedPrice: number;
|
||||
|
||||
// Quantity limits for this product
|
||||
@Column({ name: 'min_quantity', type: 'int', default: 1 })
|
||||
minQuantity: number;
|
||||
|
||||
@Column({ name: 'max_quantity', type: 'int', nullable: true })
|
||||
maxQuantity: number;
|
||||
|
||||
// For bundles - quantity included in bundle
|
||||
@Column({ name: 'bundle_quantity', type: 'int', default: 1 })
|
||||
bundleQuantity: number;
|
||||
|
||||
// For buy X get Y - quantity to get free/discounted
|
||||
@Column({ name: 'get_quantity', type: 'int', nullable: true })
|
||||
getQuantity: number;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Promotion, (promotion) => promotion.products)
|
||||
@JoinColumn({ name: 'promotion_id' })
|
||||
promotion: Promotion;
|
||||
}
|
||||
@ -1,224 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { PromotionProduct } from './promotion-product.entity';
|
||||
|
||||
export enum PromotionStatus {
|
||||
DRAFT = 'draft',
|
||||
SCHEDULED = 'scheduled',
|
||||
ACTIVE = 'active',
|
||||
PAUSED = 'paused',
|
||||
ENDED = 'ended',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export enum PromotionType {
|
||||
PERCENTAGE_DISCOUNT = 'percentage_discount',
|
||||
FIXED_DISCOUNT = 'fixed_discount',
|
||||
BUY_X_GET_Y = 'buy_x_get_y',
|
||||
BUNDLE = 'bundle',
|
||||
FLASH_SALE = 'flash_sale',
|
||||
QUANTITY_DISCOUNT = 'quantity_discount',
|
||||
FREE_SHIPPING = 'free_shipping',
|
||||
GIFT_WITH_PURCHASE = 'gift_with_purchase',
|
||||
}
|
||||
|
||||
export enum DiscountApplication {
|
||||
ORDER = 'order',
|
||||
LINE = 'line',
|
||||
SHIPPING = 'shipping',
|
||||
}
|
||||
|
||||
@Entity('promotions', { schema: 'retail' })
|
||||
@Index(['tenantId', 'status', 'startDate', 'endDate'])
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
@Index(['tenantId', 'type'])
|
||||
export class Promotion {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 30, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PromotionType,
|
||||
})
|
||||
type: PromotionType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PromotionStatus,
|
||||
default: PromotionStatus.DRAFT,
|
||||
})
|
||||
status: PromotionStatus;
|
||||
|
||||
@Column({
|
||||
name: 'discount_application',
|
||||
type: 'enum',
|
||||
enum: DiscountApplication,
|
||||
default: DiscountApplication.LINE,
|
||||
})
|
||||
discountApplication: DiscountApplication;
|
||||
|
||||
// Discount values
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'max_discount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
maxDiscount: number;
|
||||
|
||||
// Buy X Get Y config
|
||||
@Column({ name: 'buy_quantity', type: 'int', nullable: true })
|
||||
buyQuantity: number;
|
||||
|
||||
@Column({ name: 'get_quantity', type: 'int', nullable: true })
|
||||
getQuantity: number;
|
||||
|
||||
@Column({ name: 'get_discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
getDiscountPercent: number;
|
||||
|
||||
// Quantity discount config
|
||||
@Column({ name: 'quantity_tiers', type: 'jsonb', nullable: true })
|
||||
quantityTiers: {
|
||||
minQuantity: number;
|
||||
discountPercent: number;
|
||||
}[];
|
||||
|
||||
// Bundle config
|
||||
@Column({ name: 'bundle_price', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
bundlePrice: number;
|
||||
|
||||
// Validity
|
||||
@Column({ name: 'start_date', type: 'timestamp with time zone' })
|
||||
startDate: Date;
|
||||
|
||||
@Column({ name: 'end_date', type: 'timestamp with time zone', nullable: true })
|
||||
endDate: Date;
|
||||
|
||||
// Time restrictions
|
||||
@Column({ name: 'valid_days', type: 'jsonb', nullable: true })
|
||||
validDays: string[]; // ['monday', 'tuesday', ...]
|
||||
|
||||
@Column({ name: 'valid_hours_start', type: 'time', nullable: true })
|
||||
validHoursStart: string;
|
||||
|
||||
@Column({ name: 'valid_hours_end', type: 'time', nullable: true })
|
||||
validHoursEnd: string;
|
||||
|
||||
// Usage limits
|
||||
@Column({ name: 'max_uses_total', type: 'int', nullable: true })
|
||||
maxUsesTotal: number;
|
||||
|
||||
@Column({ name: 'max_uses_per_customer', type: 'int', nullable: true })
|
||||
maxUsesPerCustomer: number;
|
||||
|
||||
@Column({ name: 'current_uses', type: 'int', default: 0 })
|
||||
currentUses: number;
|
||||
|
||||
// Minimum requirements
|
||||
@Column({ name: 'min_order_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
minOrderAmount: number;
|
||||
|
||||
@Column({ name: 'min_quantity', type: 'int', nullable: true })
|
||||
minQuantity: number;
|
||||
|
||||
// Scope
|
||||
@Column({ name: 'applies_to_all_products', type: 'boolean', default: false })
|
||||
appliesToAllProducts: boolean;
|
||||
|
||||
@Column({ name: 'included_categories', type: 'jsonb', nullable: true })
|
||||
includedCategories: string[];
|
||||
|
||||
@Column({ name: 'excluded_categories', type: 'jsonb', nullable: true })
|
||||
excludedCategories: string[];
|
||||
|
||||
@Column({ name: 'excluded_products', type: 'jsonb', nullable: true })
|
||||
excludedProducts: string[];
|
||||
|
||||
@Column({ name: 'included_branches', type: 'jsonb', nullable: true })
|
||||
includedBranches: string[];
|
||||
|
||||
// Customer restrictions
|
||||
@Column({ name: 'customer_levels', type: 'jsonb', nullable: true })
|
||||
customerLevels: string[]; // membership level IDs
|
||||
|
||||
@Column({ name: 'new_customers_only', type: 'boolean', default: false })
|
||||
newCustomersOnly: boolean;
|
||||
|
||||
@Column({ name: 'loyalty_members_only', type: 'boolean', default: false })
|
||||
loyaltyMembersOnly: boolean;
|
||||
|
||||
// Stackability
|
||||
@Column({ name: 'stackable', type: 'boolean', default: false })
|
||||
stackable: boolean;
|
||||
|
||||
@Column({ name: 'stackable_with', type: 'jsonb', nullable: true })
|
||||
stackableWith: string[]; // promotion IDs that can stack
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
priority: number; // higher = applied first
|
||||
|
||||
// Display
|
||||
@Column({ name: 'display_name', length: 100, nullable: true })
|
||||
displayName: string;
|
||||
|
||||
@Column({ name: 'badge_text', length: 30, nullable: true })
|
||||
badgeText: string;
|
||||
|
||||
@Column({ name: 'badge_color', length: 7, default: '#ff0000' })
|
||||
badgeColor: string;
|
||||
|
||||
@Column({ name: 'image_url', length: 255, nullable: true })
|
||||
imageUrl: string;
|
||||
|
||||
// Channels
|
||||
@Column({ name: 'pos_enabled', type: 'boolean', default: true })
|
||||
posEnabled: boolean;
|
||||
|
||||
@Column({ name: 'ecommerce_enabled', type: 'boolean', default: true })
|
||||
ecommerceEnabled: boolean;
|
||||
|
||||
// Terms
|
||||
@Column({ name: 'terms_and_conditions', type: 'text', nullable: true })
|
||||
termsAndConditions: string;
|
||||
|
||||
// Metadata
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@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[];
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
// Services
|
||||
export * from './services';
|
||||
|
||||
// Controllers
|
||||
export * from './controllers';
|
||||
|
||||
// Routes
|
||||
export * from './routes';
|
||||
|
||||
// Validation
|
||||
export * from './validation';
|
||||
|
||||
// Entities
|
||||
export * from './entities';
|
||||
@ -1 +0,0 @@
|
||||
export { default as pricingRoutes } from './pricing.routes';
|
||||
@ -1,306 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { authMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { branchMiddleware } from '../../branches/middleware/branch.middleware';
|
||||
import { validateRequest } from '../../../shared/middleware/validation.middleware';
|
||||
import { requirePermissions } from '../../../shared/middleware/permissions.middleware';
|
||||
import {
|
||||
createPromotionSchema,
|
||||
updatePromotionSchema,
|
||||
addPromotionProductsSchema,
|
||||
listPromotionsQuerySchema,
|
||||
createCouponSchema,
|
||||
updateCouponSchema,
|
||||
generateBulkCouponsSchema,
|
||||
redeemCouponSchema,
|
||||
validateCouponSchema,
|
||||
reverseRedemptionSchema,
|
||||
listCouponsQuerySchema,
|
||||
calculatePricesSchema,
|
||||
} from '../validation/pricing.schema';
|
||||
import * as pricingController from '../controllers/pricing.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authMiddleware);
|
||||
|
||||
// ==================== PRICE ENGINE ROUTES ====================
|
||||
|
||||
/**
|
||||
* Calculate prices for cart/order items
|
||||
* Requires branch context
|
||||
*/
|
||||
router.post(
|
||||
'/calculate',
|
||||
branchMiddleware,
|
||||
validateRequest(calculatePricesSchema),
|
||||
requirePermissions('pricing:calculate'),
|
||||
pricingController.calculatePrices
|
||||
);
|
||||
|
||||
/**
|
||||
* Validate a coupon code
|
||||
* Requires branch context
|
||||
*/
|
||||
router.post(
|
||||
'/validate-coupon',
|
||||
branchMiddleware,
|
||||
validateRequest(validateCouponSchema),
|
||||
requirePermissions('pricing:validate'),
|
||||
pricingController.validateCoupon
|
||||
);
|
||||
|
||||
/**
|
||||
* Get active promotions for current branch
|
||||
* Requires branch context
|
||||
*/
|
||||
router.get(
|
||||
'/active-promotions',
|
||||
branchMiddleware,
|
||||
requirePermissions('pricing:view'),
|
||||
pricingController.getActivePromotions
|
||||
);
|
||||
|
||||
// ==================== PROMOTION ROUTES ====================
|
||||
|
||||
/**
|
||||
* List promotions with filters
|
||||
*/
|
||||
router.get(
|
||||
'/promotions',
|
||||
validateRequest(listPromotionsQuerySchema, 'query'),
|
||||
requirePermissions('promotions:view'),
|
||||
pricingController.listPromotions
|
||||
);
|
||||
|
||||
/**
|
||||
* Get promotion by ID
|
||||
*/
|
||||
router.get(
|
||||
'/promotions/:id',
|
||||
requirePermissions('promotions:view'),
|
||||
pricingController.getPromotion
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new promotion
|
||||
*/
|
||||
router.post(
|
||||
'/promotions',
|
||||
validateRequest(createPromotionSchema),
|
||||
requirePermissions('promotions:create'),
|
||||
pricingController.createPromotion
|
||||
);
|
||||
|
||||
/**
|
||||
* Update a promotion
|
||||
*/
|
||||
router.put(
|
||||
'/promotions/:id',
|
||||
validateRequest(updatePromotionSchema),
|
||||
requirePermissions('promotions:update'),
|
||||
pricingController.updatePromotion
|
||||
);
|
||||
|
||||
/**
|
||||
* Activate a promotion
|
||||
*/
|
||||
router.post(
|
||||
'/promotions/:id/activate',
|
||||
requirePermissions('promotions:activate'),
|
||||
pricingController.activatePromotion
|
||||
);
|
||||
|
||||
/**
|
||||
* Pause a promotion
|
||||
*/
|
||||
router.post(
|
||||
'/promotions/:id/pause',
|
||||
requirePermissions('promotions:update'),
|
||||
pricingController.pausePromotion
|
||||
);
|
||||
|
||||
/**
|
||||
* End a promotion
|
||||
*/
|
||||
router.post(
|
||||
'/promotions/:id/end',
|
||||
requirePermissions('promotions:update'),
|
||||
pricingController.endPromotion
|
||||
);
|
||||
|
||||
/**
|
||||
* Cancel a promotion
|
||||
*/
|
||||
router.post(
|
||||
'/promotions/:id/cancel',
|
||||
requirePermissions('promotions:delete'),
|
||||
pricingController.cancelPromotion
|
||||
);
|
||||
|
||||
/**
|
||||
* Add products to a promotion
|
||||
*/
|
||||
router.post(
|
||||
'/promotions/:id/products',
|
||||
validateRequest(addPromotionProductsSchema),
|
||||
requirePermissions('promotions:update'),
|
||||
pricingController.addPromotionProducts
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove product from a promotion
|
||||
*/
|
||||
router.delete(
|
||||
'/promotions/:id/products/:productId',
|
||||
requirePermissions('promotions:update'),
|
||||
pricingController.removePromotionProduct
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete a promotion (soft delete)
|
||||
*/
|
||||
router.delete(
|
||||
'/promotions/:id',
|
||||
requirePermissions('promotions:delete'),
|
||||
pricingController.deletePromotion
|
||||
);
|
||||
|
||||
// ==================== COUPON ROUTES ====================
|
||||
|
||||
/**
|
||||
* List coupons with filters
|
||||
*/
|
||||
router.get(
|
||||
'/coupons',
|
||||
validateRequest(listCouponsQuerySchema, 'query'),
|
||||
requirePermissions('coupons:view'),
|
||||
pricingController.listCoupons
|
||||
);
|
||||
|
||||
/**
|
||||
* Get coupon by ID
|
||||
*/
|
||||
router.get(
|
||||
'/coupons/:id',
|
||||
requirePermissions('coupons:view'),
|
||||
pricingController.getCoupon
|
||||
);
|
||||
|
||||
/**
|
||||
* Get coupon by code
|
||||
*/
|
||||
router.get(
|
||||
'/coupons/code/:code',
|
||||
requirePermissions('coupons:view'),
|
||||
pricingController.getCouponByCode
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new coupon
|
||||
*/
|
||||
router.post(
|
||||
'/coupons',
|
||||
validateRequest(createCouponSchema),
|
||||
requirePermissions('coupons:create'),
|
||||
pricingController.createCoupon
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate bulk coupons
|
||||
*/
|
||||
router.post(
|
||||
'/coupons/bulk',
|
||||
validateRequest(generateBulkCouponsSchema),
|
||||
requirePermissions('coupons:create'),
|
||||
pricingController.generateBulkCoupons
|
||||
);
|
||||
|
||||
/**
|
||||
* Update a coupon
|
||||
*/
|
||||
router.put(
|
||||
'/coupons/:id',
|
||||
validateRequest(updateCouponSchema),
|
||||
requirePermissions('coupons:update'),
|
||||
pricingController.updateCoupon
|
||||
);
|
||||
|
||||
/**
|
||||
* Activate a coupon
|
||||
*/
|
||||
router.post(
|
||||
'/coupons/:id/activate',
|
||||
requirePermissions('coupons:activate'),
|
||||
pricingController.activateCoupon
|
||||
);
|
||||
|
||||
/**
|
||||
* Deactivate a coupon
|
||||
*/
|
||||
router.post(
|
||||
'/coupons/:id/deactivate',
|
||||
requirePermissions('coupons:update'),
|
||||
pricingController.deactivateCoupon
|
||||
);
|
||||
|
||||
/**
|
||||
* Redeem a coupon by ID
|
||||
*/
|
||||
router.post(
|
||||
'/coupons/:id/redeem',
|
||||
branchMiddleware,
|
||||
validateRequest(redeemCouponSchema),
|
||||
requirePermissions('coupons:redeem'),
|
||||
pricingController.redeemCoupon
|
||||
);
|
||||
|
||||
/**
|
||||
* Redeem coupon by code
|
||||
*/
|
||||
router.post(
|
||||
'/coupons/redeem',
|
||||
branchMiddleware,
|
||||
validateRequest(redeemCouponSchema),
|
||||
requirePermissions('coupons:redeem'),
|
||||
pricingController.redeemCouponByCode
|
||||
);
|
||||
|
||||
/**
|
||||
* Reverse a coupon redemption
|
||||
*/
|
||||
router.post(
|
||||
'/coupons/redemptions/:redemptionId/reverse',
|
||||
validateRequest(reverseRedemptionSchema),
|
||||
requirePermissions('coupons:reverse'),
|
||||
pricingController.reverseRedemption
|
||||
);
|
||||
|
||||
/**
|
||||
* Get coupon redemption history
|
||||
*/
|
||||
router.get(
|
||||
'/coupons/:id/redemptions',
|
||||
requirePermissions('coupons:view'),
|
||||
pricingController.getCouponRedemptions
|
||||
);
|
||||
|
||||
/**
|
||||
* Get coupon statistics
|
||||
*/
|
||||
router.get(
|
||||
'/coupons/:id/stats',
|
||||
requirePermissions('coupons:view'),
|
||||
pricingController.getCouponStats
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete a coupon (soft delete)
|
||||
*/
|
||||
router.delete(
|
||||
'/coupons/:id',
|
||||
requirePermissions('coupons:delete'),
|
||||
pricingController.deleteCoupon
|
||||
);
|
||||
|
||||
export default router;
|
||||
@ -1,643 +0,0 @@
|
||||
import { Repository, DataSource, Between } from 'typeorm';
|
||||
import { BaseService } from '../../../shared/services/base.service';
|
||||
import { ServiceResult, QueryOptions } from '../../../shared/types';
|
||||
import { Coupon, CouponStatus, CouponType, CouponScope } from '../entities/coupon.entity';
|
||||
import { CouponRedemption, RedemptionStatus } from '../entities/coupon-redemption.entity';
|
||||
|
||||
export interface CreateCouponInput {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: CouponType;
|
||||
scope?: CouponScope;
|
||||
discountPercent?: number;
|
||||
discountAmount?: number;
|
||||
maxDiscount?: number;
|
||||
freeProductId?: string;
|
||||
freeProductQuantity?: number;
|
||||
validFrom: Date;
|
||||
validUntil?: Date;
|
||||
maxUses?: number;
|
||||
maxUsesPerCustomer?: number;
|
||||
minOrderAmount?: number;
|
||||
minItems?: number;
|
||||
appliesToCategories?: string[];
|
||||
appliesToProducts?: string[];
|
||||
excludedProducts?: string[];
|
||||
includedBranches?: string[];
|
||||
customerSpecific?: boolean;
|
||||
allowedCustomers?: string[];
|
||||
customerLevels?: string[];
|
||||
newCustomersOnly?: boolean;
|
||||
firstOrderOnly?: boolean;
|
||||
campaignId?: string;
|
||||
campaignName?: string;
|
||||
stackableWithPromotions?: boolean;
|
||||
stackableWithCoupons?: boolean;
|
||||
posEnabled?: boolean;
|
||||
ecommerceEnabled?: boolean;
|
||||
isSingleUse?: boolean;
|
||||
autoApply?: boolean;
|
||||
publicVisible?: boolean;
|
||||
termsAndConditions?: string;
|
||||
}
|
||||
|
||||
export interface RedeemCouponInput {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerId?: string;
|
||||
branchId: string;
|
||||
orderAmount: number;
|
||||
discountApplied: number;
|
||||
channel: 'pos' | 'ecommerce' | 'mobile';
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export interface CouponQueryOptions extends QueryOptions {
|
||||
status?: CouponStatus;
|
||||
type?: CouponType;
|
||||
campaignId?: string;
|
||||
branchId?: string;
|
||||
activeOnly?: boolean;
|
||||
publicOnly?: boolean;
|
||||
}
|
||||
|
||||
export class CouponService extends BaseService<Coupon> {
|
||||
constructor(
|
||||
repository: Repository<Coupon>,
|
||||
private readonly redemptionRepository: Repository<CouponRedemption>,
|
||||
private readonly dataSource: DataSource
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique coupon code
|
||||
*/
|
||||
async generateCode(tenantId: string, prefix: string = 'CPN'): Promise<string> {
|
||||
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<ServiceResult<Coupon>> {
|
||||
// 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<CreateCouponInput, 'code'>,
|
||||
quantity: number,
|
||||
prefix: string,
|
||||
userId: string
|
||||
): Promise<ServiceResult<Coupon[]>> {
|
||||
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<CreateCouponInput>,
|
||||
userId: string
|
||||
): Promise<ServiceResult<Coupon>> {
|
||||
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<ServiceResult<Coupon>> {
|
||||
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<ServiceResult<CouponRedemption>> {
|
||||
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<ServiceResult<CouponRedemption>> {
|
||||
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<ServiceResult<CouponRedemption>> {
|
||||
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<Coupon | null> {
|
||||
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<number> {
|
||||
const now = new Date();
|
||||
|
||||
const result = await this.repository.update(
|
||||
{
|
||||
tenantId,
|
||||
status: CouponStatus.ACTIVE,
|
||||
validUntil: now,
|
||||
},
|
||||
{
|
||||
status: CouponStatus.EXPIRED,
|
||||
}
|
||||
);
|
||||
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from './price-engine.service';
|
||||
export * from './promotion.service';
|
||||
export * from './coupon.service';
|
||||
@ -1,725 +0,0 @@
|
||||
import { Repository, LessThanOrEqual, MoreThanOrEqual, In } from 'typeorm';
|
||||
import {
|
||||
Promotion,
|
||||
PromotionStatus,
|
||||
PromotionType,
|
||||
DiscountApplication,
|
||||
} from '../entities/promotion.entity';
|
||||
import { PromotionProduct, ProductRole } from '../entities/promotion-product.entity';
|
||||
import { Coupon, CouponStatus, CouponType, CouponScope } from '../entities/coupon.entity';
|
||||
|
||||
export interface LineItem {
|
||||
productId: string;
|
||||
productCode: string;
|
||||
productName: string;
|
||||
variantId?: string;
|
||||
categoryId?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
originalPrice: number;
|
||||
}
|
||||
|
||||
export interface PriceContext {
|
||||
tenantId: string;
|
||||
branchId: string;
|
||||
customerId?: string;
|
||||
customerLevelId?: string;
|
||||
isNewCustomer?: boolean;
|
||||
isLoyaltyMember?: boolean;
|
||||
isFirstOrder?: boolean;
|
||||
channel: 'pos' | 'ecommerce' | 'mobile';
|
||||
}
|
||||
|
||||
export interface AppliedDiscount {
|
||||
type: 'promotion' | 'coupon';
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
discountType: PromotionType | CouponType;
|
||||
discountPercent?: number;
|
||||
discountAmount?: number;
|
||||
appliedToLineIndex?: number;
|
||||
lineDiscount?: number;
|
||||
orderDiscount?: number;
|
||||
freeProductId?: string;
|
||||
freeProductQuantity?: number;
|
||||
}
|
||||
|
||||
export interface PricedLine extends LineItem {
|
||||
lineIndex: number;
|
||||
subtotal: number;
|
||||
discountAmount: number;
|
||||
discountPercent: number;
|
||||
finalPrice: number;
|
||||
appliedPromotions: string[];
|
||||
}
|
||||
|
||||
export interface PricingResult {
|
||||
lines: PricedLine[];
|
||||
subtotal: number;
|
||||
totalLineDiscounts: number;
|
||||
orderDiscount: number;
|
||||
totalDiscount: number;
|
||||
total: number;
|
||||
appliedDiscounts: AppliedDiscount[];
|
||||
freeProducts: {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
sourcePromotion?: string;
|
||||
sourceCoupon?: string;
|
||||
}[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class PriceEngineService {
|
||||
constructor(
|
||||
private readonly promotionRepository: Repository<Promotion>,
|
||||
private readonly promotionProductRepository: Repository<PromotionProduct>,
|
||||
private readonly couponRepository: Repository<Coupon>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get active promotions for a tenant/branch
|
||||
*/
|
||||
async getActivePromotions(
|
||||
tenantId: string,
|
||||
branchId: string,
|
||||
channel: 'pos' | 'ecommerce' | 'mobile'
|
||||
): Promise<Promotion[]> {
|
||||
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<number, number>; freeProducts: { productId: string; quantity: number }[] } {
|
||||
const discounts = new Map<number, number>();
|
||||
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<PricingResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user