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