Initial commit - erp-retail-backend

This commit is contained in:
rckrdmrd 2026-01-04 07:04:02 -06:00
commit 44ce9194c2
130 changed files with 27122 additions and 0 deletions

225
docs/SPRINT-4-SUMMARY.md Normal file
View File

@ -0,0 +1,225 @@
# Sprint 4: Motor de Precios - Resumen
## Objetivo
Implementar el motor de precios completo para el vertical de Retail, incluyendo gestión de promociones, cupones y cálculo dinámico de precios.
## Componentes Implementados
### 1. Servicios
#### PriceEngineService (`src/modules/pricing/services/price-engine.service.ts`)
Motor central de cálculo de precios con las siguientes capacidades:
- **calculatePrices()**: Calcula precios para un conjunto de líneas de pedido
- Aplica promociones automáticas activas
- Soporta múltiples tipos de promoción
- Gestiona stackability (promociones acumulables)
- Aplica cupones si se proporciona código
- **getActivePromotions()**: Obtiene promociones activas para sucursal/canal
- **applyCoupon()**: Aplica un cupón al resultado de precios
- **validateCoupon()**: Valida un código de cupón sin aplicarlo
**Tipos de Promoción Soportados:**
| Tipo | Descripción |
|------|-------------|
| `percentage_discount` | Descuento porcentual |
| `fixed_discount` | Descuento de monto fijo |
| `buy_x_get_y` | Compra X, lleva Y |
| `bundle` | Precio especial por paquete |
| `flash_sale` | Venta relámpago |
| `quantity_discount` | Descuento por volumen |
| `free_shipping` | Envío gratis |
| `gift_with_purchase` | Regalo con compra |
#### PromotionService (`src/modules/pricing/services/promotion.service.ts`)
Servicio CRUD para gestión de promociones:
- **createPromotion()**: Crear promoción con validación de código único
- **updatePromotion()**: Actualizar promoción (solo en estado draft/scheduled)
- **listPromotions()**: Listar con filtros paginados
- **activatePromotion()**: Activar promoción
- **pausePromotion()**: Pausar promoción activa
- **endPromotion()**: Finalizar promoción
- **cancelPromotion()**: Cancelar promoción
- **addProducts()**: Agregar productos a promoción
- **removeProduct()**: Remover producto de promoción
- **updatePromotionStatuses()**: Actualizar estados automáticamente según fechas
**Flujo de Estados:**
```
draft → scheduled → active → ended
paused
cancelled
```
#### CouponService (`src/modules/pricing/services/coupon.service.ts`)
Servicio completo para gestión de cupones:
- **createCoupon()**: Crear cupón individual
- **generateBulkCoupons()**: Generar cupones en lote (hasta 1000)
- **updateCoupon()**: Actualizar cupón
- **activateCoupon()**: Activar cupón
- **deactivateCoupon()**: Desactivar cupón
- **redeemCoupon()**: Canjear cupón con validación completa
- **reverseRedemption()**: Revertir un canje (devolución)
- **getRedemptionHistory()**: Historial de canjes paginado
- **getCouponStats()**: Estadísticas del cupón
**Tipos de Cupón:**
- `percentage`: Descuento porcentual
- `fixed_amount`: Monto fijo
- `free_shipping`: Envío gratis
- `free_product`: Producto gratis
### 2. Validación Zod (`src/modules/pricing/validation/pricing.schema.ts`)
Schemas completos para:
**Promociones:**
- `createPromotionSchema`: Creación con todas las opciones
- `updatePromotionSchema`: Actualización parcial
- `addPromotionProductsSchema`: Agregar productos
- `listPromotionsQuerySchema`: Filtros de listado
**Cupones:**
- `createCouponSchema`: Creación con opciones completas
- `updateCouponSchema`: Actualización parcial
- `generateBulkCouponsSchema`: Generación en lote
- `redeemCouponSchema`: Canje de cupón
- `validateCouponSchema`: Validación de código
- `reverseRedemptionSchema`: Reversión de canje
- `listCouponsQuerySchema`: Filtros de listado
**Motor de Precios:**
- `calculatePricesSchema`: Entrada para cálculo
- `lineItemSchema`: Línea de producto
### 3. Controladores (`src/modules/pricing/controllers/pricing.controller.ts`)
**Endpoints del Motor de Precios (3):**
| Método | Ruta | Descripción |
|--------|------|-------------|
| POST | `/api/pricing/calculate` | Calcular precios |
| POST | `/api/pricing/validate-coupon` | Validar cupón |
| GET | `/api/pricing/active-promotions` | Promociones activas |
**Endpoints de Promociones (11):**
| Método | Ruta | Descripción |
|--------|------|-------------|
| GET | `/api/pricing/promotions` | Listar promociones |
| GET | `/api/pricing/promotions/:id` | Obtener promoción |
| POST | `/api/pricing/promotions` | Crear promoción |
| PUT | `/api/pricing/promotions/:id` | Actualizar promoción |
| POST | `/api/pricing/promotions/:id/activate` | Activar |
| POST | `/api/pricing/promotions/:id/pause` | Pausar |
| POST | `/api/pricing/promotions/:id/end` | Finalizar |
| POST | `/api/pricing/promotions/:id/cancel` | Cancelar |
| POST | `/api/pricing/promotions/:id/products` | Agregar productos |
| DELETE | `/api/pricing/promotions/:id/products/:productId` | Remover producto |
| DELETE | `/api/pricing/promotions/:id` | Eliminar (soft) |
**Endpoints de Cupones (14):**
| Método | Ruta | Descripción |
|--------|------|-------------|
| GET | `/api/pricing/coupons` | Listar cupones |
| GET | `/api/pricing/coupons/:id` | Obtener cupón |
| GET | `/api/pricing/coupons/code/:code` | Obtener por código |
| POST | `/api/pricing/coupons` | Crear cupón |
| POST | `/api/pricing/coupons/bulk` | Generar lote |
| PUT | `/api/pricing/coupons/:id` | Actualizar cupón |
| POST | `/api/pricing/coupons/:id/activate` | Activar |
| POST | `/api/pricing/coupons/:id/deactivate` | Desactivar |
| POST | `/api/pricing/coupons/:id/redeem` | Canjear por ID |
| POST | `/api/pricing/coupons/redeem` | Canjear por código |
| POST | `/api/pricing/coupons/redemptions/:id/reverse` | Revertir canje |
| GET | `/api/pricing/coupons/:id/redemptions` | Historial canjes |
| GET | `/api/pricing/coupons/:id/stats` | Estadísticas |
| DELETE | `/api/pricing/coupons/:id` | Eliminar (soft) |
**Total: 28 endpoints**
### 4. Rutas (`src/modules/pricing/routes/pricing.routes.ts`)
Configuración de rutas con:
- Autenticación JWT requerida
- Middleware de sucursal donde aplica
- Validación Zod de entrada
- Control de permisos granular
**Permisos Requeridos:**
- `pricing:calculate`, `pricing:validate`, `pricing:view`
- `promotions:view`, `promotions:create`, `promotions:update`, `promotions:activate`, `promotions:delete`
- `coupons:view`, `coupons:create`, `coupons:update`, `coupons:activate`, `coupons:redeem`, `coupons:reverse`, `coupons:delete`
## Características Destacadas
### Restricciones de Promociones
- Por fechas (inicio/fin)
- Por días de la semana
- Por horarios específicos
- Por monto mínimo de orden
- Por cantidad mínima de productos
- Por categorías incluidas/excluidas
- Por productos excluidos
- Por sucursales específicas
- Por niveles de cliente
- Solo nuevos clientes
- Solo miembros de lealtad
### Stackability (Acumulabilidad)
- Promociones pueden ser acumulables o exclusivas
- Lista de promociones con las que puede combinarse
- Prioridad numérica para resolver conflictos
### Tracking de Cupones
- Límite de usos totales
- Límite de usos por cliente
- Tracking de canjes con orden y canal
- Posibilidad de reversión con razón
## Estructura de Archivos
```
src/modules/pricing/
├── controllers/
│ ├── index.ts
│ └── pricing.controller.ts
├── entities/
│ ├── index.ts
│ ├── promotion.entity.ts
│ ├── promotion-product.entity.ts
│ ├── coupon.entity.ts
│ └── coupon-redemption.entity.ts
├── routes/
│ ├── index.ts
│ └── pricing.routes.ts
├── services/
│ ├── index.ts
│ ├── price-engine.service.ts
│ ├── promotion.service.ts
│ └── coupon.service.ts
├── validation/
│ ├── index.ts
│ └── pricing.schema.ts
└── index.ts
```
## Dependencias
- TypeORM 0.3.x
- Zod para validación
- Express para routing
- Entidades existentes: Promotion, Coupon, PromotionProduct, CouponRedemption
## Próximos Pasos
El Sprint 5 cubrirá CFDI/Facturación según el roadmap:
- Integración con PAC para timbrado
- Generación de XML CFDI 4.0
- Cancelación de facturas
- Notas de crédito

232
docs/SPRINT-5-SUMMARY.md Normal file
View File

@ -0,0 +1,232 @@
# Sprint 5: CFDI/Facturación - Resumen
## Objetivo
Implementar el sistema completo de facturación electrónica CFDI 4.0 para el vertical de Retail, con integración a PACs y soporte para autofactura.
## Componentes Implementados
### 1. Servicios
#### XMLService (`src/modules/invoicing/services/xml.service.ts`)
Servicio para construcción y manipulación de XML CFDI 4.0:
- **buildCFDIXML()**: Construye XML completo según especificación SAT
- **addSealToXML()**: Agrega sello del contribuyente
- **addCertificateToXML()**: Agrega certificado CSD
- **addTimbreToXML()**: Agrega complemento TimbreFiscalDigital
- **buildCadenaOriginal()**: Genera cadena original para firma
- **parseStampedXML()**: Parsea respuesta de PAC
- **generateQRData()**: Genera URL de verificación SAT para QR
- **validateXMLStructure()**: Validación básica de estructura
**Características:**
- Namespaces CFDI 4.0 y TFD 1.1
- Soporte para CFDIs relacionados
- Formateo de montos y tasas según SAT
- Generación de cadena original del SAT
#### PACService (`src/modules/invoicing/services/pac.service.ts`)
Servicio de integración con Proveedores Autorizados de Certificación:
- **stampCFDI()**: Timbrado de CFDI
- **cancelCFDI()**: Cancelación con motivo
- **verifyCFDIStatus()**: Consulta estado en SAT
- **encrypt()/decrypt()**: Cifrado de credenciales
- **loadCertificate()**: Carga de CSD
- **signData()**: Firma con llave privada
**PACs Soportados:**
| PAC | Timbrado | Cancelación | Verificación |
|-----|----------|-------------|--------------|
| Finkok | ✓ | ✓ | ✓ |
| Facturapi | ✓ | ✓ | - |
| SW Sapien | ✓ | - | - |
| Soluciones Fiscales | Config | Config | Config |
| Digisat | Config | Config | Config |
**Motivos de Cancelación (SAT):**
- `01`: Comprobante emitido con errores con relación
- `02`: Comprobante emitido con errores sin relación
- `03`: No se llevó a cabo la operación
- `04`: Operación nominativa relacionada en factura global
#### CFDIBuilderService (`src/modules/invoicing/services/cfdi-builder.service.ts`)
Constructor de estructuras CFDI desde datos de orden:
- **buildFromOrder()**: Construye CFDI desde líneas de pedido
- **buildCreditNote()**: Genera nota de crédito
- **buildPaymentComplement()**: Genera complemento de pago
- **validateReceptor()**: Valida datos del receptor (CFDI 4.0)
- **getUsoCFDIOptions()**: Opciones de uso CFDI por tipo de persona
- **getCommonClavesProdServ()**: Claves SAT comunes para retail
**Cálculos Automáticos:**
- Desglose de impuestos (IVA, ISR, IEPS)
- Agregación por tipo de impuesto
- Manejo de precios con/sin impuesto
- Retenciones IVA e ISR
#### CFDIService (`src/modules/invoicing/services/cfdi.service.ts`)
Servicio principal de facturación:
- **createCFDI()**: Crear factura (draft o timbrada)
- **stampCFDI()**: Timbrar factura en borrador
- **cancelCFDI()**: Cancelar factura timbrada
- **verifyCFDIStatus()**: Verificar estado en SAT
- **listCFDIs()**: Listado con filtros paginados
- **getCFDIById()**: Obtener por ID
- **getCFDIByUUID()**: Obtener por UUID
- **downloadXML()**: Descargar XML
- **resendEmail()**: Reenviar por correo
- **createCreditNote()**: Crear nota de crédito
- **getStats()**: Estadísticas de facturación
- **getActiveConfig()**: Obtener configuración activa
### 2. Validación Zod (`src/modules/invoicing/validation/cfdi.schema.ts`)
**Schemas de CFDI:**
- `receptorSchema`: Datos del receptor con validación RFC
- `orderLineSchema`: Líneas con claves SAT
- `createCFDISchema`: Creación completa
- `cancelCFDISchema`: Cancelación con motivo
- `listCFDIsQuerySchema`: Filtros de búsqueda
- `createCreditNoteSchema`: Nota de crédito
**Schemas de Configuración:**
- `createCFDIConfigSchema`: Configuración PAC y CSD
- `updateCFDIConfigSchema`: Actualización parcial
- `validateCFDIConfigSchema`: Validación de config
**Schemas de Autofactura:**
- `autofacturaRequestSchema`: Solicitud de autofactura
- `lookupOrderSchema`: Búsqueda de ticket
### 3. Controladores (`src/modules/invoicing/controllers/cfdi.controller.ts`)
**Endpoints de CFDI (14):**
| Método | Ruta | Descripción |
|--------|------|-------------|
| GET | `/cfdis` | Listar CFDIs |
| GET | `/cfdis/:id` | Obtener por ID |
| GET | `/cfdis/uuid/:uuid` | Obtener por UUID |
| POST | `/cfdis` | Crear CFDI |
| POST | `/cfdis/:id/stamp` | Timbrar |
| POST | `/cfdis/:id/cancel` | Cancelar |
| GET | `/cfdis/:id/verify` | Verificar en SAT |
| GET | `/cfdis/:id/xml` | Descargar XML |
| POST | `/cfdis/:id/resend-email` | Reenviar email |
| POST | `/cfdis/:id/credit-note` | Crear nota crédito |
| GET | `/stats` | Estadísticas |
| GET | `/cancellation-reasons` | Motivos cancelación |
| GET | `/uso-cfdi-options` | Opciones UsoCFDI |
| GET | `/claves-prod-serv` | Claves SAT |
**Endpoints de Configuración (5):**
| Método | Ruta | Descripción |
|--------|------|-------------|
| GET | `/config` | Obtener config |
| POST | `/config` | Crear/actualizar config |
| PUT | `/config` | Actualizar config |
| POST | `/config/certificate` | Subir CSD |
| POST | `/config/validate` | Validar config |
**Endpoints de Autofactura (2):**
| Método | Ruta | Descripción |
|--------|------|-------------|
| POST | `/autofactura/lookup` | Buscar ticket |
| POST | `/autofactura/create` | Generar factura |
**Total: 21 endpoints**
### 4. Rutas (`src/modules/invoicing/routes/cfdi.routes.ts`)
**Rutas Públicas:**
- Autofactura (lookup, create)
- Catálogos SAT (uso CFDI, claves prod/serv, motivos cancelación)
**Rutas Autenticadas:**
- CFDI CRUD y operaciones
- Configuración (requiere `cfdi:config:*`)
**Permisos Requeridos:**
- `cfdi:view`, `cfdi:create`, `cfdi:stamp`, `cfdi:cancel`, `cfdi:send`
- `cfdi:config:view`, `cfdi:config:manage`
## Estructura de Archivos
```
src/modules/invoicing/
├── controllers/
│ ├── index.ts
│ └── cfdi.controller.ts
├── entities/
│ ├── index.ts
│ ├── cfdi.entity.ts
│ └── cfdi-config.entity.ts
├── routes/
│ ├── index.ts
│ └── cfdi.routes.ts
├── services/
│ ├── index.ts
│ ├── cfdi.service.ts
│ ├── cfdi-builder.service.ts
│ ├── xml.service.ts
│ └── pac.service.ts
├── validation/
│ ├── index.ts
│ └── cfdi.schema.ts
└── index.ts
```
## Flujo de Facturación
```
┌────────────┐ ┌──────────────┐ ┌─────────────┐
│ Order │────▶│ CFDIBuilder │────▶│ CFDI │
│ Data │ │ Service │ │ (draft) │
└────────────┘ └──────────────┘ └──────┬──────┘
┌────────────┐ ┌──────────────┐ ┌─────────────┐
│ Stamped │◀────│ PAC │◀────│ XML │
│ CFDI │ │ Service │ │ Service │
└────────────┘ └──────────────┘ └─────────────┘
```
## Estados del CFDI
```
draft ──────▶ pending ──────▶ stamped ──────▶ cancelled
│ │
▼ ▼
error cancellation_pending
```
## Características CFDI 4.0
- Validación de RFC y régimen fiscal del receptor
- Código postal del domicilio fiscal requerido
- Objeto de impuesto por concepto
- Relacionados con tipo de relación
- Exportación (01 = No exportación)
## Dependencias
- xmlbuilder2: Construcción de XML
- crypto (Node.js): Cifrado y firma
- TypeORM 0.3.x
- Zod para validación
## Variables de Entorno Requeridas
```env
PAC_ENCRYPTION_KEY=your-32-byte-encryption-key
```
## Próximos Pasos
El Sprint 6 según el roadmap cubrirá POS Backend:
- Sesiones de POS
- Órdenes y líneas
- Pagos múltiples
- Integración con motor de precios

216
docs/SPRINT-6-SUMMARY.md Normal file
View File

@ -0,0 +1,216 @@
# Sprint 6: POS Backend - Resumen
## Objetivo
Completar y mejorar el módulo de Punto de Venta (POS) con funcionalidades avanzadas: reembolsos, órdenes en espera (hold/recall), integración con cupones y estadísticas de sesión.
## Estado Inicial
El módulo POS ya contaba con funcionalidades base:
- Apertura/cierre de sesiones
- Creación de órdenes y líneas
- Procesamiento de pagos múltiples
- Anulación de órdenes
- Búsqueda de órdenes
## Componentes Mejorados
### 1. POSOrderService (Mejoras)
#### Nuevos Métodos de Reembolso:
- **createRefund()**: Crea orden de reembolso desde orden original
- Valida orden original pagada
- Valida cantidades a reembolsar
- Genera líneas negativas
- Relaciona con orden original
- **processRefundPayment()**: Procesa pago de reembolso
- Genera movimiento de efectivo negativo
- Actualiza totales de sesión
- Marca reembolso como completado
#### Nuevos Métodos Hold/Recall:
- **holdOrder()**: Pone orden en espera ("parking")
- Solo aplica a órdenes en draft
- Guarda nombre/referencia de la orden
- Cambia estado a confirmado con metadata
- **getHeldOrders()**: Lista órdenes en espera por sucursal
- **recallOrder()**: Recupera orden en espera
- Asigna a sesión/caja actual
- Vuelve a estado draft
#### Métodos Adicionales:
- **applyCouponToOrder()**: Aplica cupón a orden
- **markReceiptPrinted()**: Marca ticket como impreso
- **sendReceiptEmail()**: Envía ticket por correo
- **getSessionStats()**: Estadísticas de sesión
### 2. Controladores Nuevos
**Endpoints de Reembolso (2):**
| Método | Ruta | Descripción | Permisos |
|--------|------|-------------|----------|
| POST | `/pos/refunds` | Crear reembolso | supervisor+ |
| POST | `/pos/refunds/:id/process` | Procesar pago | supervisor+ |
**Endpoints Hold/Recall (3):**
| Método | Ruta | Descripción |
|--------|------|-------------|
| POST | `/pos/orders/:id/hold` | Poner en espera |
| GET | `/pos/orders/held` | Listar en espera |
| POST | `/pos/orders/:id/recall` | Recuperar orden |
**Endpoints Adicionales (4):**
| Método | Ruta | Descripción |
|--------|------|-------------|
| POST | `/pos/orders/:id/coupon` | Aplicar cupón |
| POST | `/pos/orders/:id/receipt/print` | Marcar impreso |
| POST | `/pos/orders/:id/receipt/send` | Enviar email |
| GET | `/pos/sessions/:id/stats` | Estadísticas |
**Total nuevos endpoints: 9**
**Total endpoints POS: 21**
### 3. Validación Zod (Nuevos Schemas)
```typescript
// Refunds
refundLineSchema
createRefundSchema
processRefundPaymentSchema
// Hold/Recall
holdOrderSchema
recallOrderSchema
// Coupon
applyCouponSchema
```
## Estructura de Archivos
```
src/modules/pos/
├── controllers/
│ └── pos.controller.ts (605 líneas)
├── entities/
│ ├── index.ts
│ ├── pos-session.entity.ts
│ ├── pos-order.entity.ts
│ ├── pos-order-line.entity.ts
│ └── pos-payment.entity.ts
├── routes/
│ └── pos.routes.ts (143 líneas)
├── services/
│ ├── index.ts
│ ├── pos-session.service.ts (361 líneas)
│ └── pos-order.service.ts (915 líneas)
├── validation/
│ └── pos.schema.ts (207 líneas)
└── index.ts
```
## Flujos de Negocio
### Flujo de Reembolso
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Original │────▶│ Create │────▶│ Refund │
│ Order │ │ Refund │ │ (draft) │
│ (paid) │ │ │ │ │
└─────────────┘ └─────────────┘ └──────┬──────┘
┌─────────────┐ ┌─────────────┐
│ Refund │◀────│ Process │
│ (paid) │ │ Payment │
└─────────────┘ └─────────────┘
```
### Flujo Hold/Recall
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Order │────▶│ Hold │────▶│ Order │
│ (draft) │ │ Order │ │ (held) │
└─────────────┘ └─────────────┘ └──────┬──────┘
┌────────────────────────────────────────┘
┌─────────────┐ ┌─────────────┐
│ Recall │────▶│ Order │
│ Order │ │ (draft) │
└─────────────┘ └─────────────┘
```
## Tipos de Orden
| Tipo | Código | Descripción |
|------|--------|-------------|
| SALE | sale | Venta normal |
| REFUND | refund | Devolución |
| EXCHANGE | exchange | Cambio |
## Estados de Orden
| Estado | Descripción |
|--------|-------------|
| DRAFT | Orden en proceso |
| CONFIRMED | Confirmada / En espera (hold) |
| PAID | Pagada completamente |
| PARTIALLY_PAID | Pago parcial |
| VOIDED | Anulada |
| REFUNDED | Reembolsada |
## Permisos Requeridos
| Acción | Roles Permitidos |
|--------|------------------|
| Crear orden | Todos autenticados |
| Agregar pago | Todos autenticados |
| Anular orden | admin, manager, supervisor |
| Crear reembolso | admin, manager, supervisor |
| Procesar reembolso | admin, manager, supervisor |
| Hold/Recall | Todos autenticados |
## Estadísticas de Sesión
El endpoint `/pos/sessions/:id/stats` devuelve:
```typescript
{
totalOrders: number; // Órdenes completadas
totalSales: number; // Ventas totales
totalRefunds: number; // Reembolsos totales
totalDiscounts: number; // Descuentos aplicados
averageTicket: number; // Ticket promedio
paymentBreakdown: { // Desglose por método
cash: number;
credit_card: number;
debit_card: number;
// ...
};
}
```
## Integración Pendiente
- [ ] Integración completa con PriceEngineService para cupones
- [ ] Envío real de email para receipts
- [ ] Generación de PDF de ticket
- [ ] Integración con impresora térmica
## Dependencias
- TypeORM 0.3.x
- Zod para validación
- BaseService para operaciones CRUD
- Middleware de autenticación y roles
## Próximos Pasos
Sprint 7 según roadmap: POS Frontend
- Layout de interfaz POS
- Pantalla de ventas
- Pantalla de cobro
- Componentes de producto
- Componentes de pago

61
package.json Normal file
View File

@ -0,0 +1,61 @@
{
"name": "@erp-suite/retail-backend",
"version": "0.1.0",
"description": "Backend for ERP Retail vertical - Point of Sale, Inventory, E-commerce",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"test": "jest",
"typeorm": "typeorm-ts-node-commonjs",
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.ts",
"migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.ts"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"compression": "^1.7.4",
"morgan": "^1.10.0",
"dotenv": "^16.3.1",
"pg": "^8.11.3",
"typeorm": "^0.3.28",
"reflect-metadata": "^0.2.2",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3",
"uuid": "^9.0.1",
"zod": "^3.22.4",
"winston": "^3.11.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"socket.io": "^4.7.4"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"@types/morgan": "^1.9.9",
"@types/node": "^20.10.6",
"@types/jsonwebtoken": "^9.0.5",
"@types/bcryptjs": "^2.4.6",
"@types/uuid": "^9.0.7",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.6",
"typescript": "^5.3.3",
"tsx": "^4.6.2",
"eslint": "^8.56.0",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.11"
},
"engines": {
"node": ">=20.0.0"
}
}

120
src/app.ts Normal file
View File

@ -0,0 +1,120 @@
import 'reflect-metadata';
import express, { Application, Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import morgan from 'morgan';
// Route imports
import branchRoutes from './modules/branches/routes/branch.routes';
import posRoutes from './modules/pos/routes/pos.routes';
// Error type
interface AppError extends Error {
statusCode?: number;
status?: string;
isOperational?: boolean;
}
// Create Express app
const app: Application = express();
// Security middleware
app.use(helmet());
// CORS configuration
app.use(cors({
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000', 'http://localhost:5173'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Tenant-ID', 'X-Branch-ID'],
}));
// Compression
app.use(compression());
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Request logging
if (process.env.NODE_ENV !== 'test') {
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
}
// Health check endpoint
app.get('/health', (_req: Request, res: Response) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '0.1.0',
environment: process.env.NODE_ENV || 'development',
});
});
// API version info
app.get('/api', (_req: Request, res: Response) => {
res.json({
name: 'ERP Retail API',
version: '0.1.0',
modules: [
'branches',
'pos',
'cash',
'inventory',
'customers',
'pricing',
'invoicing',
'ecommerce',
'purchases',
],
});
});
// API Routes
app.use('/api/branches', branchRoutes);
app.use('/api/pos', posRoutes);
// TODO: Add remaining route modules as they are implemented
// app.use('/api/cash', cashRouter);
// app.use('/api/inventory', inventoryRouter);
// app.use('/api/customers', customersRouter);
// app.use('/api/pricing', pricingRouter);
// app.use('/api/invoicing', invoicingRouter);
// app.use('/api/ecommerce', ecommerceRouter);
// app.use('/api/purchases', purchasesRouter);
// 404 handler
app.use((_req: Request, res: Response) => {
res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: 'The requested resource was not found',
},
});
});
// Global error handler
app.use((err: AppError, _req: Request, res: Response, _next: NextFunction) => {
const statusCode = err.statusCode || 500;
const status = err.status || 'error';
// Log error
console.error('Error:', {
message: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
statusCode,
});
// Send response
res.status(statusCode).json({
success: false,
error: {
code: status.toUpperCase().replace(/ /g, '_'),
message: err.isOperational ? err.message : 'An unexpected error occurred',
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
},
});
});
export default app;

102
src/config/database.ts Normal file
View File

@ -0,0 +1,102 @@
import { Pool, PoolClient, QueryResult } from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'erp_retail',
user: process.env.DB_USER || 'retail_admin',
password: process.env.DB_PASSWORD,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
process.exit(-1);
});
export async function query<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 };

144
src/config/typeorm.ts Normal file
View File

@ -0,0 +1,144 @@
import 'reflect-metadata';
import { DataSource, DataSourceOptions } from 'typeorm';
import dotenv from 'dotenv';
// Entities - Branches
import { Branch } from '../modules/branches/entities/branch.entity';
import { CashRegister } from '../modules/branches/entities/cash-register.entity';
import { BranchUser } from '../modules/branches/entities/branch-user.entity';
// Entities - POS
import { POSSession } from '../modules/pos/entities/pos-session.entity';
import { POSOrder } from '../modules/pos/entities/pos-order.entity';
import { POSOrderLine } from '../modules/pos/entities/pos-order-line.entity';
import { POSPayment } from '../modules/pos/entities/pos-payment.entity';
// Entities - Cash
import { CashMovement } from '../modules/cash/entities/cash-movement.entity';
import { CashClosing } from '../modules/cash/entities/cash-closing.entity';
import { CashCount } from '../modules/cash/entities/cash-count.entity';
// Entities - Inventory
import { StockTransfer } from '../modules/inventory/entities/stock-transfer.entity';
import { StockTransferLine } from '../modules/inventory/entities/stock-transfer-line.entity';
import { StockAdjustment } from '../modules/inventory/entities/stock-adjustment.entity';
import { StockAdjustmentLine } from '../modules/inventory/entities/stock-adjustment-line.entity';
// Entities - Customers
import { LoyaltyProgram } from '../modules/customers/entities/loyalty-program.entity';
import { MembershipLevel } from '../modules/customers/entities/membership-level.entity';
import { LoyaltyTransaction } from '../modules/customers/entities/loyalty-transaction.entity';
import { CustomerMembership } from '../modules/customers/entities/customer-membership.entity';
// Entities - Pricing
import { Promotion } from '../modules/pricing/entities/promotion.entity';
import { PromotionProduct } from '../modules/pricing/entities/promotion-product.entity';
import { Coupon } from '../modules/pricing/entities/coupon.entity';
import { CouponRedemption } from '../modules/pricing/entities/coupon-redemption.entity';
// Entities - Invoicing
import { CFDIConfig } from '../modules/invoicing/entities/cfdi-config.entity';
import { CFDI } from '../modules/invoicing/entities/cfdi.entity';
// Entities - E-commerce
import { Cart } from '../modules/ecommerce/entities/cart.entity';
import { CartItem } from '../modules/ecommerce/entities/cart-item.entity';
import { EcommerceOrder } from '../modules/ecommerce/entities/ecommerce-order.entity';
import { EcommerceOrderLine } from '../modules/ecommerce/entities/ecommerce-order-line.entity';
import { ShippingRate } from '../modules/ecommerce/entities/shipping-rate.entity';
// Entities - Purchases
import { PurchaseSuggestion } from '../modules/purchases/entities/purchase-suggestion.entity';
import { SupplierOrder } from '../modules/purchases/entities/supplier-order.entity';
import { SupplierOrderLine } from '../modules/purchases/entities/supplier-order-line.entity';
import { GoodsReceipt } from '../modules/purchases/entities/goods-receipt.entity';
import { GoodsReceiptLine } from '../modules/purchases/entities/goods-receipt-line.entity';
dotenv.config();
const isProduction = process.env.NODE_ENV === 'production';
const baseConfig: DataSourceOptions = {
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USER || 'retail_admin',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'erp_retail',
schema: 'retail',
entities: [
// Branches
Branch,
CashRegister,
BranchUser,
// POS
POSSession,
POSOrder,
POSOrderLine,
POSPayment,
// Cash
CashMovement,
CashClosing,
CashCount,
// Inventory
StockTransfer,
StockTransferLine,
StockAdjustment,
StockAdjustmentLine,
// Customers
LoyaltyProgram,
MembershipLevel,
LoyaltyTransaction,
CustomerMembership,
// Pricing
Promotion,
PromotionProduct,
Coupon,
CouponRedemption,
// Invoicing
CFDIConfig,
CFDI,
// E-commerce
Cart,
CartItem,
EcommerceOrder,
EcommerceOrderLine,
ShippingRate,
// Purchases
PurchaseSuggestion,
SupplierOrder,
SupplierOrderLine,
GoodsReceipt,
GoodsReceiptLine,
],
migrations: ['src/migrations/*.ts'],
synchronize: false, // NEVER use in production
logging: isProduction ? ['error'] : ['query', 'error'],
maxQueryExecutionTime: 1000, // Log slow queries
extra: {
max: 10,
idleTimeoutMillis: 30000,
},
};
export const AppDataSource = new DataSource(baseConfig);
export async function initTypeORM(): Promise<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');
}
}

76
src/index.ts Normal file
View File

@ -0,0 +1,76 @@
import 'reflect-metadata';
import dotenv from 'dotenv';
// Load environment variables first
dotenv.config();
import app from './app';
import { initDatabase, closeDatabase } from './config/database';
import { initTypeORM, closeTypeORM } from './config/typeorm';
const PORT = parseInt(process.env.PORT || '3001', 10);
const HOST = process.env.HOST || '0.0.0.0';
async function bootstrap(): Promise<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();

View File

@ -0,0 +1,236 @@
import { Response, NextFunction } from 'express';
import { BaseController } from '../../../shared/controllers/base.controller';
import { AuthenticatedRequest } from '../../../shared/types';
import { branchService } from '../services/branch.service';
import { BranchStatus, BranchType } from '../entities/branch.entity';
class BranchController extends BaseController {
/**
* GET /branches - List all branches
*/
async list(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<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();

View File

@ -0,0 +1,122 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Branch } from './branch.entity';
export enum BranchUserRole {
CASHIER = 'cashier',
SUPERVISOR = 'supervisor',
MANAGER = 'manager',
INVENTORY = 'inventory',
ADMIN = 'admin',
}
@Entity('branch_users', { schema: 'retail' })
@Index(['tenantId', 'branchId', 'userId'], { unique: true })
@Index(['tenantId', 'userId'])
export class BranchUser {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
// Reference to erp-core user
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({
type: 'enum',
enum: BranchUserRole,
default: BranchUserRole.CASHIER,
})
role: BranchUserRole;
// PIN for quick login at POS
@Column({ name: 'pos_pin', length: 6, nullable: true })
posPin: string;
// Permissions (granular override of role)
@Column({ type: 'jsonb', nullable: true })
permissions: {
canOpenSession?: boolean;
canCloseSession?: boolean;
canVoidSale?: boolean;
canApplyDiscount?: boolean;
maxDiscountPercent?: number;
canRefund?: boolean;
canViewReports?: boolean;
canManageInventory?: boolean;
canManagePrices?: boolean;
canManageUsers?: boolean;
};
// Commission settings
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 4, nullable: true })
commissionRate: number;
@Column({ name: 'commission_type', length: 20, nullable: true })
commissionType: 'percentage' | 'fixed_per_sale' | 'fixed_per_item';
// Work schedule
@Column({ name: 'work_schedule', type: 'jsonb', nullable: true })
workSchedule: {
monday?: { start: string; end: string };
tuesday?: { start: string; end: string };
wednesday?: { start: string; end: string };
thursday?: { start: string; end: string };
friday?: { start: string; end: string };
saturday?: { start: string; end: string };
sunday?: { start: string; end: string };
};
// Default register assignment
@Column({ name: 'default_register_id', type: 'uuid', nullable: true })
defaultRegisterId: string;
// Activity tracking
@Column({ name: 'last_login_at', type: 'timestamp with time zone', nullable: true })
lastLoginAt: Date;
@Column({ name: 'last_activity_at', type: 'timestamp with time zone', nullable: true })
lastActivityAt: Date;
// Status
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_primary_branch', type: 'boolean', default: false })
isPrimaryBranch: boolean;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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;
}

View File

@ -0,0 +1,148 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { CashRegister } from './cash-register.entity';
import { BranchUser } from './branch-user.entity';
export enum BranchType {
STORE = 'store',
WAREHOUSE = 'warehouse',
HYBRID = 'hybrid',
}
export enum BranchStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
TEMPORARILY_CLOSED = 'temporarily_closed',
}
@Entity('branches', { schema: 'retail' })
@Index(['tenantId', 'code'], { unique: true })
@Index(['tenantId', 'status'])
export class Branch {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ length: 20, unique: true })
code: string;
@Column({ length: 100 })
name: string;
@Column({
type: 'enum',
enum: BranchType,
default: BranchType.STORE,
})
type: BranchType;
@Column({
type: 'enum',
enum: BranchStatus,
default: BranchStatus.ACTIVE,
})
status: BranchStatus;
// Address
@Column({ length: 255, nullable: true })
address: string;
@Column({ length: 100, nullable: true })
city: string;
@Column({ length: 100, nullable: true })
state: string;
@Column({ name: 'postal_code', length: 10, nullable: true })
postalCode: string;
@Column({ name: 'country_code', length: 3, default: 'MEX' })
countryCode: string;
// Contact
@Column({ length: 20, nullable: true })
phone: string;
@Column({ length: 100, nullable: true })
email: string;
// Manager reference (from erp-core users)
@Column({ name: 'manager_id', type: 'uuid', nullable: true })
managerId: string;
// Inventory reference (from erp-core)
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId: string;
// Operating hours (JSON)
@Column({ name: 'operating_hours', type: 'jsonb', nullable: true })
operatingHours: {
monday?: { open: string; close: string };
tuesday?: { open: string; close: string };
wednesday?: { open: string; close: string };
thursday?: { open: string; close: string };
friday?: { open: string; close: string };
saturday?: { open: string; close: string };
sunday?: { open: string; close: string };
};
// POS Configuration
@Column({ name: 'default_price_list_id', type: 'uuid', nullable: true })
defaultPriceListId: string;
@Column({ name: 'allow_negative_stock', type: 'boolean', default: false })
allowNegativeStock: boolean;
@Column({ name: 'require_customer_for_sale', type: 'boolean', default: false })
requireCustomerForSale: boolean;
@Column({ name: 'auto_print_receipt', type: 'boolean', default: true })
autoPrintReceipt: boolean;
// Fiscal
@Column({ name: 'fiscal_address', type: 'text', nullable: true })
fiscalAddress: string;
@Column({ name: 'cfdi_config_id', type: 'uuid', nullable: true })
cfdiConfigId: string;
// Geolocation
@Column({ type: 'decimal', precision: 10, scale: 7, nullable: true })
latitude: number;
@Column({ type: 'decimal', precision: 10, scale: 7, nullable: true })
longitude: number;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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[];
}

View File

@ -0,0 +1,147 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Branch } from './branch.entity';
export enum RegisterStatus {
AVAILABLE = 'available',
IN_USE = 'in_use',
CLOSED = 'closed',
MAINTENANCE = 'maintenance',
}
export enum RegisterType {
STANDARD = 'standard',
EXPRESS = 'express',
SELF_SERVICE = 'self_service',
}
@Entity('cash_registers', { schema: 'retail' })
@Index(['tenantId', 'branchId', 'code'], { unique: true })
@Index(['tenantId', 'status'])
export class CashRegister {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@Column({ length: 20 })
code: string;
@Column({ length: 100 })
name: string;
@Column({
type: 'enum',
enum: RegisterType,
default: RegisterType.STANDARD,
})
type: RegisterType;
@Column({
type: 'enum',
enum: RegisterStatus,
default: RegisterStatus.AVAILABLE,
})
status: RegisterStatus;
// Hardware configuration
@Column({ name: 'printer_config', type: 'jsonb', nullable: true })
printerConfig: {
type: 'thermal' | 'inkjet' | 'none';
connectionType: 'usb' | 'network' | 'bluetooth';
address?: string;
port?: number;
paperWidth: 58 | 80;
};
@Column({ name: 'scanner_config', type: 'jsonb', nullable: true })
scannerConfig: {
enabled: boolean;
type: 'usb' | 'bluetooth';
};
@Column({ name: 'cash_drawer_config', type: 'jsonb', nullable: true })
cashDrawerConfig: {
enabled: boolean;
openCommand: string;
};
@Column({ name: 'display_config', type: 'jsonb', nullable: true })
displayConfig: {
enabled: boolean;
type: 'lcd' | 'pole' | 'none';
};
// Payment methods allowed
@Column({ name: 'allowed_payment_methods', type: 'jsonb', default: '["cash", "card", "transfer"]' })
allowedPaymentMethods: string[];
// Current session info (denormalized for quick access)
@Column({ name: 'current_session_id', type: 'uuid', nullable: true })
currentSessionId: string;
@Column({ name: 'current_user_id', type: 'uuid', nullable: true })
currentUserId: string;
// Cash limits
@Column({ name: 'initial_cash_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
initialCashAmount: number;
@Column({ name: 'max_cash_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
maxCashAmount: number;
// Settings
@Column({ name: 'require_closing_count', type: 'boolean', default: true })
requireClosingCount: boolean;
@Column({ name: 'allow_blind_close', type: 'boolean', default: false })
allowBlindClose: boolean;
@Column({ name: 'auto_logout_minutes', type: 'int', default: 30 })
autoLogoutMinutes: number;
// Device identifier for offline sync
@Column({ name: 'device_uuid', type: 'uuid', nullable: true })
deviceUuid: string;
@Column({ name: 'last_sync_at', type: 'timestamp with time zone', nullable: true })
lastSyncAt: Date;
// Active flag
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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;
}

View File

@ -0,0 +1,3 @@
export * from './branch.entity';
export * from './cash-register.entity';
export * from './branch-user.entity';

View File

@ -0,0 +1,3 @@
export * from './entities';
export * from './services';
export { default as branchRoutes } from './routes/branch.routes';

View File

@ -0,0 +1,48 @@
import { Router } from 'express';
import { branchController } from '../controllers/branch.controller';
import { authMiddleware, requireRoles } from '../../../shared/middleware/auth.middleware';
import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware';
import { AuthenticatedRequest } from '../../../shared/types';
const router = Router();
// All routes require tenant and authentication
router.use(tenantMiddleware);
router.use(authMiddleware);
// List routes
router.get('/', (req, res, next) => branchController.list(req as AuthenticatedRequest, res, next));
router.get('/active', (req, res, next) => branchController.listActive(req as AuthenticatedRequest, res, next));
router.get('/nearby', (req, res, next) => branchController.findNearby(req as AuthenticatedRequest, res, next));
// Single branch routes
router.get('/code/:code', (req, res, next) => branchController.getByCode(req as AuthenticatedRequest, res, next));
router.get('/:id', (req, res, next) => branchController.getById(req as AuthenticatedRequest, res, next));
router.get('/:id/stats', (req, res, next) => branchController.getStats(req as AuthenticatedRequest, res, next));
// Admin routes (require manager or admin role)
router.post(
'/',
requireRoles('admin', 'manager'),
(req, res, next) => branchController.create(req as AuthenticatedRequest, res, next)
);
router.put(
'/:id',
requireRoles('admin', 'manager'),
(req, res, next) => branchController.update(req as AuthenticatedRequest, res, next)
);
router.patch(
'/:id/status',
requireRoles('admin', 'manager'),
(req, res, next) => branchController.updateStatus(req as AuthenticatedRequest, res, next)
);
router.delete(
'/:id',
requireRoles('admin'),
(req, res, next) => branchController.delete(req as AuthenticatedRequest, res, next)
);
export default router;

View File

@ -0,0 +1,243 @@
import { Repository, DeepPartial } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult, QueryOptions } from '../../../shared/types';
import { Branch, BranchStatus, BranchType } from '../entities/branch.entity';
import { CashRegister } from '../entities/cash-register.entity';
export class BranchService extends BaseService<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();

View File

@ -0,0 +1 @@
export * from './branch.service';

View File

@ -0,0 +1,82 @@
import { z } from 'zod';
import {
uuidSchema,
emailSchema,
phoneSchema,
postalCodeSchema,
operatingHoursSchema,
coordinatesSchema,
paginationSchema,
} from '../../../shared/validation/common.schema';
// Enums
export const branchTypeEnum = z.enum(['store', 'warehouse', 'hybrid']);
export const branchStatusEnum = z.enum(['active', 'inactive', 'temporarily_closed']);
// Create branch schema
export const createBranchSchema = z.object({
code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'),
name: z.string().min(1).max(100),
type: branchTypeEnum.default('store'),
status: branchStatusEnum.default('active'),
// Address
address: z.string().max(255).optional(),
city: z.string().max(100).optional(),
state: z.string().max(100).optional(),
postalCode: postalCodeSchema.optional(),
countryCode: z.string().length(3).default('MEX'),
// Contact
phone: phoneSchema.optional(),
email: emailSchema.optional(),
// References
managerId: uuidSchema.optional(),
warehouseId: uuidSchema.optional(),
// Configuration
operatingHours: operatingHoursSchema,
defaultPriceListId: uuidSchema.optional(),
allowNegativeStock: z.boolean().default(false),
requireCustomerForSale: z.boolean().default(false),
autoPrintReceipt: z.boolean().default(true),
// Fiscal
fiscalAddress: z.string().optional(),
cfdiConfigId: uuidSchema.optional(),
// Geolocation
latitude: z.coerce.number().min(-90).max(90).optional(),
longitude: z.coerce.number().min(-180).max(180).optional(),
// Metadata
metadata: z.record(z.any()).optional(),
});
// Update branch schema (all fields optional)
export const updateBranchSchema = createBranchSchema.partial().omit({ code: true });
// Update status schema
export const updateBranchStatusSchema = z.object({
status: branchStatusEnum,
});
// List branches query schema
export const listBranchesQuerySchema = paginationSchema.extend({
status: branchStatusEnum.optional(),
type: branchTypeEnum.optional(),
search: z.string().optional(),
});
// Find nearby query schema
export const findNearbyQuerySchema = z.object({
lat: z.coerce.number().min(-90).max(90),
lng: z.coerce.number().min(-180).max(180),
radius: z.coerce.number().min(0.1).max(100).default(10),
});
// Types
export type CreateBranchInput = z.infer<typeof createBranchSchema>;
export type UpdateBranchInput = z.infer<typeof updateBranchSchema>;
export type ListBranchesQuery = z.infer<typeof listBranchesQuerySchema>;

View File

@ -0,0 +1,330 @@
import { Request, Response } from 'express';
import { BaseController } from '../../../shared/controllers/base.controller';
import { CashMovementService, MovementQueryOptions } from '../services/cash-movement.service';
import { CashClosingService, ClosingQueryOptions } from '../services/cash-closing.service';
export class CashController extends BaseController {
constructor(
private readonly movementService: CashMovementService,
private readonly closingService: CashClosingService
) {
super();
}
// ==================== MOVEMENT ENDPOINTS ====================
/**
* Create a cash movement
* POST /cash/movements
*/
createMovement = async (req: Request, res: Response): Promise<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);
};
}

View File

@ -0,0 +1,205 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { CashCount } from './cash-count.entity';
export enum ClosingStatus {
IN_PROGRESS = 'in_progress',
PENDING_REVIEW = 'pending_review',
APPROVED = 'approved',
REJECTED = 'rejected',
RECONCILED = 'reconciled',
}
export enum ClosingType {
SHIFT = 'shift',
DAILY = 'daily',
WEEKLY = 'weekly',
MONTHLY = 'monthly',
}
@Entity('cash_closings', { schema: 'retail' })
@Index(['tenantId', 'branchId', 'closingDate'])
@Index(['tenantId', 'status'])
@Index(['tenantId', 'sessionId'])
export class CashClosing {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@Column({ name: 'register_id', type: 'uuid', nullable: true })
registerId: string;
@Column({ name: 'session_id', type: 'uuid', nullable: true })
sessionId: string;
@Column({ length: 30, unique: true })
number: string;
@Column({
type: 'enum',
enum: ClosingType,
default: ClosingType.SHIFT,
})
type: ClosingType;
@Column({
type: 'enum',
enum: ClosingStatus,
default: ClosingStatus.IN_PROGRESS,
})
status: ClosingStatus;
@Column({ name: 'closing_date', type: 'date' })
closingDate: Date;
@Column({ name: 'period_start', type: 'timestamp with time zone' })
periodStart: Date;
@Column({ name: 'period_end', type: 'timestamp with time zone' })
periodEnd: Date;
// Opening balance
@Column({ name: 'opening_balance', type: 'decimal', precision: 15, scale: 2, default: 0 })
openingBalance: number;
// Expected amounts by payment method
@Column({ name: 'expected_cash', type: 'decimal', precision: 15, scale: 2, default: 0 })
expectedCash: number;
@Column({ name: 'expected_card', type: 'decimal', precision: 15, scale: 2, default: 0 })
expectedCard: number;
@Column({ name: 'expected_transfer', type: 'decimal', precision: 15, scale: 2, default: 0 })
expectedTransfer: number;
@Column({ name: 'expected_other', type: 'decimal', precision: 15, scale: 2, default: 0 })
expectedOther: number;
@Column({ name: 'expected_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
expectedTotal: number;
// Counted amounts
@Column({ name: 'counted_cash', type: 'decimal', precision: 15, scale: 2, nullable: true })
countedCash: number;
@Column({ name: 'counted_card', type: 'decimal', precision: 15, scale: 2, nullable: true })
countedCard: number;
@Column({ name: 'counted_transfer', type: 'decimal', precision: 15, scale: 2, nullable: true })
countedTransfer: number;
@Column({ name: 'counted_other', type: 'decimal', precision: 15, scale: 2, nullable: true })
countedOther: number;
@Column({ name: 'counted_total', type: 'decimal', precision: 15, scale: 2, nullable: true })
countedTotal: number;
// Differences
@Column({ name: 'cash_difference', type: 'decimal', precision: 15, scale: 2, nullable: true })
cashDifference: number;
@Column({ name: 'card_difference', type: 'decimal', precision: 15, scale: 2, nullable: true })
cardDifference: number;
@Column({ name: 'total_difference', type: 'decimal', precision: 15, scale: 2, nullable: true })
totalDifference: number;
// Transaction summary
@Column({ name: 'total_sales', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalSales: number;
@Column({ name: 'total_refunds', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalRefunds: number;
@Column({ name: 'total_discounts', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalDiscounts: number;
@Column({ name: 'net_sales', type: 'decimal', precision: 15, scale: 2, default: 0 })
netSales: number;
@Column({ name: 'orders_count', type: 'int', default: 0 })
ordersCount: number;
@Column({ name: 'refunds_count', type: 'int', default: 0 })
refundsCount: number;
@Column({ name: 'voided_count', type: 'int', default: 0 })
voidedCount: number;
// Cash movements
@Column({ name: 'cash_in_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
cashInTotal: number;
@Column({ name: 'cash_out_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
cashOutTotal: number;
// Deposit info
@Column({ name: 'deposit_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
depositAmount: number;
@Column({ name: 'deposit_reference', length: 100, nullable: true })
depositReference: string;
@Column({ name: 'deposit_date', type: 'date', nullable: true })
depositDate: Date;
// User info
@Column({ name: 'closed_by', type: 'uuid' })
closedBy: string;
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
reviewedBy: string;
@Column({ name: 'reviewed_at', type: 'timestamp with time zone', nullable: true })
reviewedAt: Date;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedBy: string;
@Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true })
approvedAt: Date;
// Notes
@Column({ name: 'closing_notes', type: 'text', nullable: true })
closingNotes: string;
@Column({ name: 'review_notes', type: 'text', nullable: true })
reviewNotes: string;
// Detailed breakdown by payment method
@Column({ name: 'payment_breakdown', type: 'jsonb', nullable: true })
paymentBreakdown: {
method: string;
expected: number;
counted: number;
difference: number;
transactionCount: number;
}[];
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => CashCount, (count) => count.closing)
cashCounts: CashCount[];
}

View File

@ -0,0 +1,78 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { CashClosing } from './cash-closing.entity';
export enum DenominationType {
BILL = 'bill',
COIN = 'coin',
}
@Entity('cash_counts', { schema: 'retail' })
@Index(['tenantId', 'closingId'])
@Index(['tenantId', 'sessionId'])
export class CashCount {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'closing_id', type: 'uuid', nullable: true })
closingId: string;
@Column({ name: 'session_id', type: 'uuid', nullable: true })
sessionId: string;
@Column({ name: 'register_id', type: 'uuid' })
registerId: string;
@Column({
type: 'enum',
enum: DenominationType,
})
type: DenominationType;
// Denomination value (e.g., 500, 200, 100, 50, 20, 10, 5, 2, 1, 0.50, 0.20, 0.10)
@Column({ type: 'decimal', precision: 10, scale: 2 })
denomination: number;
// Count
@Column({ type: 'int', default: 0 })
quantity: number;
// Calculated total (denomination * quantity)
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
total: number;
// Currency
@Column({ name: 'currency_code', length: 3, default: 'MXN' })
currencyCode: string;
// Count context
@Column({ name: 'count_type', length: 20, default: 'closing' })
countType: 'opening' | 'closing' | 'audit' | 'transfer';
// User who counted
@Column({ name: 'counted_by', type: 'uuid' })
countedBy: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => CashClosing, (closing) => closing.cashCounts)
@JoinColumn({ name: 'closing_id' })
closing: CashClosing;
}

View File

@ -0,0 +1,152 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export enum MovementType {
CASH_IN = 'cash_in',
CASH_OUT = 'cash_out',
DEPOSIT = 'deposit',
WITHDRAWAL = 'withdrawal',
ADJUSTMENT = 'adjustment',
OPENING = 'opening',
CLOSING = 'closing',
}
export enum MovementReason {
CHANGE_FUND = 'change_fund',
PETTY_CASH = 'petty_cash',
BANK_DEPOSIT = 'bank_deposit',
SUPPLIER_PAYMENT = 'supplier_payment',
EXPENSE = 'expense',
SALARY_ADVANCE = 'salary_advance',
CORRECTION = 'correction',
OTHER = 'other',
}
export enum MovementStatus {
PENDING = 'pending',
APPROVED = 'approved',
REJECTED = 'rejected',
CANCELLED = 'cancelled',
}
@Entity('cash_movements', { schema: 'retail' })
@Index(['tenantId', 'branchId', 'createdAt'])
@Index(['tenantId', 'sessionId'])
@Index(['tenantId', 'type', 'status'])
export class CashMovement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@Column({ name: 'register_id', type: 'uuid' })
registerId: string;
@Column({ name: 'session_id', type: 'uuid' })
sessionId: string;
@Column({ length: 30, unique: true })
number: string;
@Column({
type: 'enum',
enum: MovementType,
})
type: MovementType;
@Column({
type: 'enum',
enum: MovementReason,
default: MovementReason.OTHER,
})
reason: MovementReason;
@Column({
type: 'enum',
enum: MovementStatus,
default: MovementStatus.PENDING,
})
status: MovementStatus;
// Amount (positive for in, negative for out stored as absolute)
@Column({ type: 'decimal', precision: 15, scale: 2 })
amount: number;
// Running balance after movement
@Column({ name: 'balance_after', type: 'decimal', precision: 15, scale: 2, nullable: true })
balanceAfter: number;
// Description
@Column({ type: 'text' })
description: string;
// Reference (external document, invoice, etc.)
@Column({ name: 'reference_type', length: 50, nullable: true })
referenceType: string;
@Column({ name: 'reference_id', type: 'uuid', nullable: true })
referenceId: string;
@Column({ name: 'reference_number', length: 50, nullable: true })
referenceNumber: string;
// Authorization
@Column({ name: 'requires_approval', type: 'boolean', default: false })
requiresApproval: boolean;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedBy: string;
@Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true })
approvedAt: Date;
@Column({ name: 'rejection_reason', length: 255, nullable: true })
rejectionReason: string;
// Person who receives/gives cash (for expenses, advances, etc.)
@Column({ name: 'recipient_name', length: 200, nullable: true })
recipientName: string;
@Column({ name: 'recipient_id', type: 'uuid', nullable: true })
recipientId: string;
// Supporting documents
@Column({ name: 'attachment_ids', type: 'jsonb', nullable: true })
attachmentIds: string[];
// Financial account (for accounting integration)
@Column({ name: 'account_id', type: 'uuid', nullable: true })
accountId: string;
// Bank deposit info
@Column({ name: 'bank_account', length: 50, nullable: true })
bankAccount: string;
@Column({ name: 'deposit_slip_number', length: 50, nullable: true })
depositSlipNumber: string;
// User who created the movement
@Column({ name: 'created_by', type: 'uuid' })
createdBy: string;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@ -0,0 +1,3 @@
export * from './cash-movement.entity';
export * from './cash-closing.entity';
export * from './cash-count.entity';

View File

@ -0,0 +1,5 @@
export * from './entities';
export * from './services';
export * from './controllers/cash.controller';
export * from './routes/cash.routes';
export * from './validation/cash.schema';

View File

@ -0,0 +1,155 @@
import { Router } from 'express';
import { CashController } from '../controllers/cash.controller';
import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware';
import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware';
import { idParamSchema } from '../../../shared/validation/common.schema';
import {
createMovementSchema,
movementActionSchema,
listMovementsQuerySchema,
createClosingSchema,
submitCashCountSchema,
submitPaymentCountsSchema,
closingActionSchema,
rejectClosingSchema,
reconcileClosingSchema,
listClosingsQuerySchema,
} from '../validation/cash.schema';
export function createCashRoutes(controller: CashController): Router {
const router = Router();
// Apply auth middleware to all routes
router.use(requireAuth);
// ==================== MOVEMENT ROUTES ====================
// Create movement
router.post(
'/movements',
requirePermissions(['cash.movements.create']),
validateBody(createMovementSchema),
controller.createMovement
);
// List movements
router.get(
'/movements',
requirePermissions(['cash.movements.view']),
validateQuery(listMovementsQuerySchema),
controller.listMovements
);
// Get movement by ID
router.get(
'/movements/:id',
requirePermissions(['cash.movements.view']),
controller.getMovement
);
// Approve movement
router.post(
'/movements/:id/approve',
requireRoles(['admin', 'supervisor', 'manager']),
controller.approveMovement
);
// Reject movement
router.post(
'/movements/:id/reject',
requireRoles(['admin', 'supervisor', 'manager']),
validateBody(movementActionSchema),
controller.rejectMovement
);
// Cancel movement
router.post(
'/movements/:id/cancel',
requirePermissions(['cash.movements.cancel']),
validateBody(movementActionSchema),
controller.cancelMovement
);
// Get session summary
router.get(
'/sessions/:sessionId/summary',
requirePermissions(['cash.movements.view']),
controller.getSessionSummary
);
// ==================== CLOSING ROUTES ====================
// Create closing
router.post(
'/closings',
requirePermissions(['cash.closings.create']),
validateBody(createClosingSchema),
controller.createClosing
);
// List closings
router.get(
'/closings',
requirePermissions(['cash.closings.view']),
validateQuery(listClosingsQuerySchema),
controller.listClosings
);
// Get closing by ID
router.get(
'/closings/:id',
requirePermissions(['cash.closings.view']),
controller.getClosing
);
// Submit cash count
router.post(
'/closings/:id/count',
requirePermissions(['cash.closings.count']),
validateBody(submitCashCountSchema),
controller.submitCashCount
);
// Submit payment counts
router.post(
'/closings/:id/payments',
requirePermissions(['cash.closings.count']),
validateBody(submitPaymentCountsSchema),
controller.submitPaymentCounts
);
// Approve closing
router.post(
'/closings/:id/approve',
requireRoles(['admin', 'supervisor', 'manager']),
validateBody(closingActionSchema),
controller.approveClosing
);
// Reject closing
router.post(
'/closings/:id/reject',
requireRoles(['admin', 'supervisor', 'manager']),
validateBody(rejectClosingSchema),
controller.rejectClosing
);
// Reconcile closing
router.post(
'/closings/:id/reconcile',
requireRoles(['admin', 'accountant']),
validateBody(reconcileClosingSchema),
controller.reconcileClosing
);
// ==================== SUMMARY ROUTES ====================
// Get daily summary
router.get(
'/summary/daily',
requirePermissions(['cash.reports.view']),
controller.getDailySummary
);
return router;
}

View File

@ -0,0 +1,568 @@
import { Repository, DataSource, Between } from 'typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult, QueryOptions } from '../../../shared/types';
import {
CashClosing,
ClosingStatus,
ClosingType,
} from '../entities/cash-closing.entity';
import { CashCount, DenominationType } from '../entities/cash-count.entity';
import { CashMovement, MovementStatus } from '../entities/cash-movement.entity';
export interface CreateClosingInput {
branchId: string;
registerId?: string;
sessionId?: string;
type: ClosingType;
periodStart: Date;
periodEnd: Date;
openingBalance: number;
closingNotes?: string;
}
export interface ClosingQueryOptions extends QueryOptions {
branchId?: string;
sessionId?: string;
status?: ClosingStatus;
type?: ClosingType;
startDate?: Date;
endDate?: Date;
}
export interface DenominationCount {
type: DenominationType;
denomination: number;
quantity: number;
}
export interface PaymentTotals {
cash: number;
card: number;
transfer: number;
other: number;
}
export class CashClosingService extends BaseService<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,
};
}
}

View File

@ -0,0 +1,398 @@
import { Repository, DataSource, Between, In } from 'typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult, QueryOptions } from '../../../shared/types';
import {
CashMovement,
MovementType,
MovementReason,
MovementStatus,
} from '../entities/cash-movement.entity';
export interface CreateMovementInput {
branchId: string;
registerId: string;
sessionId: string;
type: MovementType;
reason?: MovementReason;
amount: number;
description: string;
referenceType?: string;
referenceId?: string;
referenceNumber?: string;
requiresApproval?: boolean;
recipientName?: string;
recipientId?: string;
accountId?: string;
bankAccount?: string;
depositSlipNumber?: string;
notes?: string;
metadata?: Record<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();
}
}

View File

@ -0,0 +1,2 @@
export * from './cash-movement.service';
export * from './cash-closing.service';

View File

@ -0,0 +1,160 @@
import { z } from 'zod';
import {
uuidSchema,
moneySchema,
paginationSchema,
} from '../../../shared/validation/common.schema';
// Enums
export const movementTypeEnum = z.enum([
'cash_in',
'cash_out',
'deposit',
'withdrawal',
'adjustment',
'opening',
'closing',
]);
export const movementReasonEnum = z.enum([
'change_fund',
'petty_cash',
'bank_deposit',
'supplier_payment',
'expense',
'salary_advance',
'correction',
'other',
]);
export const movementStatusEnum = z.enum([
'pending',
'approved',
'rejected',
'cancelled',
]);
export const closingTypeEnum = z.enum(['shift', 'daily', 'weekly', 'monthly']);
export const closingStatusEnum = z.enum([
'in_progress',
'pending_review',
'approved',
'rejected',
'reconciled',
]);
export const denominationTypeEnum = z.enum(['bill', 'coin']);
// ==================== MOVEMENT SCHEMAS ====================
// Create movement schema
export const createMovementSchema = z.object({
branchId: uuidSchema,
registerId: uuidSchema,
sessionId: uuidSchema,
type: movementTypeEnum,
reason: movementReasonEnum.optional(),
amount: moneySchema.positive('Amount must be positive'),
description: z.string().min(1, 'Description is required').max(500),
referenceType: z.string().max(50).optional(),
referenceId: uuidSchema.optional(),
referenceNumber: z.string().max(50).optional(),
requiresApproval: z.boolean().optional(),
recipientName: z.string().max(200).optional(),
recipientId: uuidSchema.optional(),
accountId: uuidSchema.optional(),
bankAccount: z.string().max(50).optional(),
depositSlipNumber: z.string().max(50).optional(),
notes: z.string().max(1000).optional(),
metadata: z.record(z.any()).optional(),
});
// Approve/reject movement schema
export const movementActionSchema = z.object({
reason: z.string().max(255).optional(),
});
// List movements query schema
export const listMovementsQuerySchema = paginationSchema.extend({
branchId: uuidSchema.optional(),
sessionId: uuidSchema.optional(),
registerId: uuidSchema.optional(),
type: movementTypeEnum.optional(),
reason: movementReasonEnum.optional(),
status: movementStatusEnum.optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
});
// ==================== CLOSING SCHEMAS ====================
// Create closing schema
export const createClosingSchema = z.object({
branchId: uuidSchema,
registerId: uuidSchema.optional(),
sessionId: uuidSchema.optional(),
type: closingTypeEnum,
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
openingBalance: moneySchema.default(0),
closingNotes: z.string().max(1000).optional(),
}).refine(data => data.periodStart <= data.periodEnd, {
message: 'Period start must be before or equal to period end',
path: ['periodEnd'],
});
// Denomination count schema
export const denominationCountSchema = z.object({
type: denominationTypeEnum,
denomination: z.number().positive(),
quantity: z.number().int().min(0),
});
// Submit cash count schema
export const submitCashCountSchema = z.object({
denominations: z.array(denominationCountSchema).min(1, 'At least one denomination is required'),
});
// Submit payment counts schema
export const submitPaymentCountsSchema = z.object({
cash: moneySchema.optional(),
card: moneySchema.default(0),
transfer: moneySchema.default(0),
other: moneySchema.default(0),
});
// Closing action schema (approve/reject)
export const closingActionSchema = z.object({
notes: z.string().max(1000).optional(),
});
// Reject closing schema (requires notes)
export const rejectClosingSchema = z.object({
notes: z.string().min(1, 'Rejection reason is required').max(1000),
});
// Reconcile closing schema
export const reconcileClosingSchema = z.object({
depositAmount: moneySchema.positive('Deposit amount must be positive'),
depositReference: z.string().min(1).max(100),
depositDate: z.coerce.date(),
});
// List closings query schema
export const listClosingsQuerySchema = paginationSchema.extend({
branchId: uuidSchema.optional(),
sessionId: uuidSchema.optional(),
status: closingStatusEnum.optional(),
type: closingTypeEnum.optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
});
// Types
export type CreateMovementInput = z.infer<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>;

View File

@ -0,0 +1,222 @@
import { Request, Response } from 'express';
import { BaseController } from '../../../shared/controllers/base.controller';
import { LoyaltyService, MembershipQueryOptions } from '../services/loyalty.service';
export class LoyaltyController extends BaseController {
constructor(private readonly loyaltyService: LoyaltyService) {
super();
}
// ==================== PROGRAM ENDPOINTS ====================
/**
* Get active loyalty program
* GET /loyalty/program
*/
getActiveProgram = async (req: Request, res: Response): Promise<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);
};
}

View File

@ -0,0 +1,161 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum MembershipStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
SUSPENDED = 'suspended',
EXPIRED = 'expired',
}
@Entity('customer_memberships', { schema: 'retail' })
@Index(['tenantId', 'customerId', 'programId'], { unique: true })
@Index(['tenantId', 'cardNumber'], { unique: true })
@Index(['tenantId', 'status'])
@Index(['tenantId', 'levelId'])
export class CustomerMembership {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
// Customer (from erp-core partners)
@Column({ name: 'customer_id', type: 'uuid' })
customerId: string;
@Column({ name: 'program_id', type: 'uuid' })
programId: string;
@Column({ name: 'level_id', type: 'uuid' })
levelId: string;
@Column({ name: 'card_number', length: 20, unique: true })
cardNumber: string;
@Column({
type: 'enum',
enum: MembershipStatus,
default: MembershipStatus.ACTIVE,
})
status: MembershipStatus;
// Points
@Column({ name: 'points_balance', type: 'int', default: 0 })
pointsBalance: number;
@Column({ name: 'lifetime_points', type: 'int', default: 0 })
lifetimePoints: number;
@Column({ name: 'points_redeemed', type: 'int', default: 0 })
pointsRedeemed: number;
@Column({ name: 'points_expired', type: 'int', default: 0 })
pointsExpired: number;
@Column({ name: 'points_expiring_soon', type: 'int', default: 0 })
pointsExpiringSoon: number;
@Column({ name: 'next_expiration_date', type: 'date', nullable: true })
nextExpirationDate: Date;
@Column({ name: 'next_expiration_points', type: 'int', default: 0 })
nextExpirationPoints: number;
// Purchase stats
@Column({ name: 'total_purchases', type: 'int', default: 0 })
totalPurchases: number;
@Column({ name: 'total_spend', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalSpend: number;
@Column({ name: 'average_order_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
averageOrderValue: number;
// Period stats (for level qualification)
@Column({ name: 'period_purchases', type: 'int', default: 0 })
periodPurchases: number;
@Column({ name: 'period_spend', type: 'decimal', precision: 15, scale: 2, default: 0 })
periodSpend: number;
@Column({ name: 'period_points', type: 'int', default: 0 })
periodPoints: number;
@Column({ name: 'period_start_date', type: 'date', nullable: true })
periodStartDate: Date;
@Column({ name: 'period_end_date', type: 'date', nullable: true })
periodEndDate: Date;
// Level history
@Column({ name: 'level_achieved_at', type: 'timestamp with time zone', nullable: true })
levelAchievedAt: Date;
@Column({ name: 'level_expires_at', type: 'date', nullable: true })
levelExpiresAt: Date;
@Column({ name: 'previous_level_id', type: 'uuid', nullable: true })
previousLevelId: string;
// Dates
@Column({ name: 'enrolled_at', type: 'timestamp with time zone' })
enrolledAt: Date;
@Column({ name: 'last_activity_at', type: 'timestamp with time zone', nullable: true })
lastActivityAt: Date;
@Column({ name: 'last_purchase_at', type: 'timestamp with time zone', nullable: true })
lastPurchaseAt: Date;
@Column({ name: 'last_redemption_at', type: 'timestamp with time zone', nullable: true })
lastRedemptionAt: Date;
// Referral
@Column({ name: 'referral_code', length: 20, nullable: true })
referralCode: string;
@Column({ name: 'referred_by_id', type: 'uuid', nullable: true })
referredById: string;
@Column({ name: 'referrals_count', type: 'int', default: 0 })
referralsCount: number;
// Communication preferences
@Column({ name: 'email_notifications', type: 'boolean', default: true })
emailNotifications: boolean;
@Column({ name: 'sms_notifications', type: 'boolean', default: false })
smsNotifications: boolean;
@Column({ name: 'push_notifications', type: 'boolean', default: true })
pushNotifications: boolean;
// Favorite branch
@Column({ name: 'favorite_branch_id', type: 'uuid', nullable: true })
favoriteBranchId: string;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
}

View File

@ -0,0 +1,4 @@
export * from './loyalty-program.entity';
export * from './membership-level.entity';
export * from './loyalty-transaction.entity';
export * from './customer-membership.entity';

View File

@ -0,0 +1,161 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { MembershipLevel } from './membership-level.entity';
export enum ProgramStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
SUSPENDED = 'suspended',
}
export enum PointsCalculation {
FIXED_PER_PURCHASE = 'fixed_per_purchase',
PERCENTAGE_OF_AMOUNT = 'percentage_of_amount',
POINTS_PER_CURRENCY = 'points_per_currency',
}
@Entity('loyalty_programs', { schema: 'retail' })
@Index(['tenantId', 'status'])
@Index(['tenantId', 'code'], { unique: true })
export class LoyaltyProgram {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ length: 20, unique: true })
code: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({
type: 'enum',
enum: ProgramStatus,
default: ProgramStatus.ACTIVE,
})
status: ProgramStatus;
// Points configuration
@Column({
name: 'points_calculation',
type: 'enum',
enum: PointsCalculation,
default: PointsCalculation.POINTS_PER_CURRENCY,
})
pointsCalculation: PointsCalculation;
@Column({ name: 'points_per_currency', type: 'decimal', precision: 10, scale: 4, default: 1 })
pointsPerCurrency: number; // e.g., 1 point per $10 MXN
@Column({ name: 'currency_per_point', type: 'decimal', precision: 10, scale: 2, default: 10 })
currencyPerPoint: number; // e.g., $10 MXN = 1 point
@Column({ name: 'minimum_purchase', type: 'decimal', precision: 15, scale: 2, default: 0 })
minimumPurchase: number;
@Column({ name: 'round_points', type: 'boolean', default: true })
roundPoints: boolean;
@Column({ name: 'round_direction', length: 10, default: 'down' })
roundDirection: 'up' | 'down' | 'nearest';
// Redemption configuration
@Column({ name: 'points_value', type: 'decimal', precision: 10, scale: 4, default: 0.1 })
pointsValue: number; // Value of 1 point in currency (e.g., 1 point = $0.10)
@Column({ name: 'min_points_redemption', type: 'int', default: 100 })
minPointsRedemption: number;
@Column({ name: 'max_redemption_percent', type: 'decimal', precision: 5, scale: 2, default: 100 })
maxRedemptionPercent: number; // Max % of purchase that can be paid with points
@Column({ name: 'allow_partial_redemption', type: 'boolean', default: true })
allowPartialRedemption: boolean;
// Expiration
@Column({ name: 'points_expire', type: 'boolean', default: true })
pointsExpire: boolean;
@Column({ name: 'expiration_months', type: 'int', default: 12 })
expirationMonths: number;
@Column({ name: 'expiration_policy', length: 20, default: 'fifo' })
expirationPolicy: 'fifo' | 'lifo' | 'oldest_first';
// Bonus configuration
@Column({ name: 'welcome_bonus', type: 'int', default: 0 })
welcomeBonus: number;
@Column({ name: 'birthday_bonus', type: 'int', default: 0 })
birthdayBonus: number;
@Column({ name: 'referral_bonus', type: 'int', default: 0 })
referralBonus: number;
@Column({ name: 'referee_bonus', type: 'int', default: 0 })
refereeBonus: number;
// Multipliers
@Column({ name: 'double_points_days', type: 'jsonb', nullable: true })
doublePointsDays: string[]; // ['monday', 'tuesday', etc.]
@Column({ name: 'category_multipliers', type: 'jsonb', nullable: true })
categoryMultipliers: {
categoryId: string;
multiplier: number;
}[];
// Restrictions
@Column({ name: 'excluded_categories', type: 'jsonb', nullable: true })
excludedCategories: string[];
@Column({ name: 'excluded_products', type: 'jsonb', nullable: true })
excludedProducts: string[];
@Column({ name: 'included_branches', type: 'jsonb', nullable: true })
includedBranches: string[]; // null = all branches
// Valid dates
@Column({ name: 'valid_from', type: 'date', nullable: true })
validFrom: Date;
@Column({ name: 'valid_until', type: 'date', nullable: true })
validUntil: Date;
// Terms
@Column({ name: 'terms_and_conditions', type: 'text', nullable: true })
termsAndConditions: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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[];
}

View File

@ -0,0 +1,144 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export enum TransactionType {
EARN = 'earn',
REDEEM = 'redeem',
EXPIRE = 'expire',
ADJUST = 'adjust',
BONUS = 'bonus',
TRANSFER_IN = 'transfer_in',
TRANSFER_OUT = 'transfer_out',
REFUND = 'refund',
}
export enum TransactionStatus {
PENDING = 'pending',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
REVERSED = 'reversed',
}
@Entity('loyalty_transactions', { schema: 'retail' })
@Index(['tenantId', 'customerId', 'createdAt'])
@Index(['tenantId', 'programId', 'type'])
@Index(['tenantId', 'membershipId'])
@Index(['tenantId', 'orderId'])
export class LoyaltyTransaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'program_id', type: 'uuid' })
programId: string;
@Column({ name: 'membership_id', type: 'uuid' })
membershipId: string;
@Column({ name: 'customer_id', type: 'uuid' })
customerId: string;
@Column({ length: 30, unique: true })
number: string;
@Column({
type: 'enum',
enum: TransactionType,
})
type: TransactionType;
@Column({
type: 'enum',
enum: TransactionStatus,
default: TransactionStatus.COMPLETED,
})
status: TransactionStatus;
// Points
@Column({ type: 'int' })
points: number; // positive for earn, negative for redeem
@Column({ name: 'points_balance_after', type: 'int' })
pointsBalanceAfter: number;
// Value
@Column({ name: 'monetary_value', type: 'decimal', precision: 15, scale: 2, nullable: true })
monetaryValue: number;
@Column({ name: 'purchase_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
purchaseAmount: number;
// Reference
@Column({ name: 'order_id', type: 'uuid', nullable: true })
orderId: string;
@Column({ name: 'order_number', length: 30, nullable: true })
orderNumber: string;
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
branchId: string;
// Bonus type (for bonus transactions)
@Column({ name: 'bonus_type', length: 30, nullable: true })
bonusType: 'welcome' | 'birthday' | 'referral' | 'referee' | 'campaign' | 'manual';
@Column({ name: 'campaign_id', type: 'uuid', nullable: true })
campaignId: string;
// Multipliers applied
@Column({ name: 'base_points', type: 'int', nullable: true })
basePoints: number;
@Column({ name: 'multiplier_applied', type: 'decimal', precision: 5, scale: 2, nullable: true })
multiplierApplied: number;
@Column({ name: 'multiplier_reason', length: 100, nullable: true })
multiplierReason: string;
// Expiration
@Column({ name: 'expires_at', type: 'date', nullable: true })
expiresAt: Date;
@Column({ name: 'expired', type: 'boolean', default: false })
expired: boolean;
// For transfers
@Column({ name: 'transfer_to_customer_id', type: 'uuid', nullable: true })
transferToCustomerId: string;
@Column({ name: 'transfer_from_customer_id', type: 'uuid', nullable: true })
transferFromCustomerId: string;
// Description
@Column({ type: 'text', nullable: true })
description: string;
// Reversal
@Column({ name: 'reversed_by_id', type: 'uuid', nullable: true })
reversedById: string;
@Column({ name: 'reversal_of_id', type: 'uuid', nullable: true })
reversalOfId: string;
@Column({ name: 'reversal_reason', length: 255, nullable: true })
reversalReason: string;
// User
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@ -0,0 +1,135 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { LoyaltyProgram } from './loyalty-program.entity';
export enum LevelStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
}
@Entity('membership_levels', { schema: 'retail' })
@Index(['tenantId', 'programId', 'level'])
@Index(['tenantId', 'code'], { unique: true })
export class MembershipLevel {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'program_id', type: 'uuid' })
programId: string;
@Column({ length: 20, unique: true })
code: string;
@Column({ length: 50 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'int', default: 0 })
level: number; // 0 = base level, higher = better
@Column({
type: 'enum',
enum: LevelStatus,
default: LevelStatus.ACTIVE,
})
status: LevelStatus;
// Qualification criteria
@Column({ name: 'min_points_required', type: 'int', default: 0 })
minPointsRequired: number;
@Column({ name: 'min_purchases_required', type: 'int', default: 0 })
minPurchasesRequired: number;
@Column({ name: 'min_spend_required', type: 'decimal', precision: 15, scale: 2, default: 0 })
minSpendRequired: number;
@Column({ name: 'qualification_period_months', type: 'int', default: 12 })
qualificationPeriodMonths: number;
// Benefits
@Column({ name: 'points_multiplier', type: 'decimal', precision: 5, scale: 2, default: 1 })
pointsMultiplier: number;
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPercent: number;
@Column({ name: 'free_shipping', type: 'boolean', default: false })
freeShipping: boolean;
@Column({ name: 'free_shipping_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true })
freeShippingThreshold: number;
@Column({ name: 'priority_support', type: 'boolean', default: false })
prioritySupport: boolean;
@Column({ name: 'early_access', type: 'boolean', default: false })
earlyAccess: boolean;
@Column({ name: 'exclusive_promotions', type: 'boolean', default: false })
exclusivePromotions: boolean;
@Column({ name: 'birthday_multiplier', type: 'decimal', precision: 5, scale: 2, default: 1 })
birthdayMultiplier: number;
// Custom benefits
@Column({ name: 'custom_benefits', type: 'jsonb', nullable: true })
customBenefits: {
name: string;
description: string;
value?: string;
}[];
// Display
@Column({ length: 7, default: '#000000' })
color: string;
@Column({ name: 'icon_url', length: 255, nullable: true })
iconUrl: string;
@Column({ name: 'badge_url', length: 255, nullable: true })
badgeUrl: string;
// Retention
@Column({ name: 'retention_months', type: 'int', default: 12 })
retentionMonths: number;
@Column({ name: 'downgrade_level_id', type: 'uuid', nullable: true })
downgradeLevelId: string;
// Auto-upgrade
@Column({ name: 'auto_upgrade', type: 'boolean', default: true })
autoUpgrade: boolean;
@Column({ name: 'upgrade_notification', type: 'boolean', default: true })
upgradeNotification: boolean;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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;
}

View File

@ -0,0 +1,5 @@
export * from './entities';
export * from './services';
export * from './controllers/loyalty.controller';
export * from './routes/loyalty.routes';
export * from './validation/customers.schema';

View File

@ -0,0 +1,113 @@
import { Router } from 'express';
import { LoyaltyController } from '../controllers/loyalty.controller';
import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware';
import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware';
import {
enrollCustomerSchema,
listMembershipsQuerySchema,
earnPointsSchema,
redeemPointsSchema,
adjustPointsSchema,
calculatePointsSchema,
lookupByCardSchema,
listTransactionsQuerySchema,
} from '../validation/customers.schema';
export function createLoyaltyRoutes(controller: LoyaltyController): Router {
const router = Router();
// Apply auth middleware to all routes
router.use(requireAuth);
// ==================== PROGRAM ROUTES ====================
// Get active program
router.get(
'/program',
requirePermissions(['loyalty.view']),
controller.getActiveProgram
);
// ==================== MEMBERSHIP ROUTES ====================
// Enroll customer
router.post(
'/enroll',
requirePermissions(['loyalty.enroll']),
validateBody(enrollCustomerSchema),
controller.enrollCustomer
);
// List memberships
router.get(
'/memberships',
requirePermissions(['loyalty.view']),
validateQuery(listMembershipsQuerySchema),
controller.listMemberships
);
// Get membership by customer
router.get(
'/memberships/customer/:customerId',
requirePermissions(['loyalty.view']),
controller.getMembershipByCustomer
);
// Get membership by card
router.get(
'/memberships/card/:cardNumber',
requirePermissions(['loyalty.view']),
controller.getMembershipByCard
);
// Get expiring points
router.get(
'/memberships/:membershipId/expiring',
requirePermissions(['loyalty.view']),
controller.getExpiringPoints
);
// Get transaction history
router.get(
'/memberships/:membershipId/transactions',
requirePermissions(['loyalty.view']),
validateQuery(listTransactionsQuerySchema),
controller.getTransactionHistory
);
// ==================== POINTS ROUTES ====================
// Calculate points preview
router.post(
'/points/calculate',
requirePermissions(['loyalty.view']),
validateBody(calculatePointsSchema),
controller.calculatePoints
);
// Earn points
router.post(
'/points/earn',
requirePermissions(['loyalty.earn']),
validateBody(earnPointsSchema),
controller.earnPoints
);
// Redeem points
router.post(
'/points/redeem',
requirePermissions(['loyalty.redeem']),
validateBody(redeemPointsSchema),
controller.redeemPoints
);
// Adjust points
router.post(
'/points/adjust',
requireRoles(['admin', 'manager']),
validateBody(adjustPointsSchema),
controller.adjustPoints
);
return router;
}

View File

@ -0,0 +1 @@
export * from './loyalty.service';

View File

@ -0,0 +1,842 @@
import { Repository, DataSource, Between, LessThanOrEqual, MoreThan } from 'typeorm';
import { ServiceResult, QueryOptions } from '../../../shared/types';
import { LoyaltyProgram, ProgramStatus, PointsCalculation } from '../entities/loyalty-program.entity';
import { MembershipLevel, LevelStatus } from '../entities/membership-level.entity';
import { CustomerMembership, MembershipStatus } from '../entities/customer-membership.entity';
import { LoyaltyTransaction, TransactionType, TransactionStatus } from '../entities/loyalty-transaction.entity';
export interface EnrollCustomerInput {
customerId: string;
programId: string;
referredById?: string;
emailNotifications?: boolean;
smsNotifications?: boolean;
}
export interface EarnPointsInput {
membershipId: string;
purchaseAmount: number;
orderId?: string;
orderNumber?: string;
branchId?: string;
categoryMultipliers?: { categoryId: string; amount: number }[];
}
export interface RedeemPointsInput {
membershipId: string;
points: number;
orderId?: string;
orderNumber?: string;
branchId?: string;
description?: string;
}
export interface AdjustPointsInput {
membershipId: string;
points: number;
reason: string;
}
export interface MembershipQueryOptions extends QueryOptions {
programId?: string;
levelId?: string;
status?: MembershipStatus;
customerId?: string;
}
export class LoyaltyService {
constructor(
private readonly programRepository: Repository<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 };
}
}

View File

@ -0,0 +1,179 @@
import { z } from 'zod';
import {
uuidSchema,
moneySchema,
paginationSchema,
} from '../../../shared/validation/common.schema';
// Enums
export const programStatusEnum = z.enum(['active', 'inactive', 'suspended']);
export const membershipStatusEnum = z.enum(['active', 'inactive', 'suspended', 'expired']);
export const transactionTypeEnum = z.enum([
'earn',
'redeem',
'expire',
'adjust',
'bonus',
'transfer_in',
'transfer_out',
'refund',
]);
// ==================== PROGRAM SCHEMAS ====================
// Create program schema
export const createProgramSchema = z.object({
code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'),
name: z.string().min(1).max(100),
description: z.string().max(1000).optional(),
pointsCalculation: z.enum(['fixed_per_purchase', 'percentage_of_amount', 'points_per_currency']).default('points_per_currency'),
pointsPerCurrency: z.number().positive().default(1),
currencyPerPoint: z.number().positive().default(10),
minimumPurchase: moneySchema.default(0),
roundPoints: z.boolean().default(true),
roundDirection: z.enum(['up', 'down', 'nearest']).default('down'),
pointsValue: z.number().positive().default(0.1),
minPointsRedemption: z.number().int().positive().default(100),
maxRedemptionPercent: z.number().min(0).max(100).default(100),
allowPartialRedemption: z.boolean().default(true),
pointsExpire: z.boolean().default(true),
expirationMonths: z.number().int().min(1).max(120).default(12),
welcomeBonus: z.number().int().min(0).default(0),
birthdayBonus: z.number().int().min(0).default(0),
referralBonus: z.number().int().min(0).default(0),
refereeBonus: z.number().int().min(0).default(0),
validFrom: z.coerce.date().optional(),
validUntil: z.coerce.date().optional(),
termsAndConditions: z.string().max(10000).optional(),
});
// Update program schema
export const updateProgramSchema = createProgramSchema.partial().omit({ code: true });
// ==================== LEVEL SCHEMAS ====================
// Create level schema
export const createLevelSchema = z.object({
programId: uuidSchema,
code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'),
name: z.string().min(1).max(50),
description: z.string().max(500).optional(),
level: z.number().int().min(0).default(0),
minPointsRequired: z.number().int().min(0).default(0),
minPurchasesRequired: z.number().int().min(0).default(0),
minSpendRequired: moneySchema.default(0),
qualificationPeriodMonths: z.number().int().min(1).max(24).default(12),
pointsMultiplier: z.number().min(1).max(10).default(1),
discountPercent: z.number().min(0).max(100).default(0),
freeShipping: z.boolean().default(false),
freeShippingThreshold: moneySchema.optional(),
prioritySupport: z.boolean().default(false),
earlyAccess: z.boolean().default(false),
exclusivePromotions: z.boolean().default(false),
birthdayMultiplier: z.number().min(1).max(10).default(1),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid color format').default('#000000'),
retentionMonths: z.number().int().min(1).max(36).default(12),
autoUpgrade: z.boolean().default(true),
});
// Update level schema
export const updateLevelSchema = createLevelSchema.partial().omit({ programId: true, code: true });
// ==================== MEMBERSHIP SCHEMAS ====================
// Enroll customer schema
export const enrollCustomerSchema = z.object({
customerId: uuidSchema,
programId: uuidSchema,
referredById: uuidSchema.optional(),
emailNotifications: z.boolean().default(true),
smsNotifications: z.boolean().default(false),
});
// Update membership schema
export const updateMembershipSchema = z.object({
status: membershipStatusEnum.optional(),
emailNotifications: z.boolean().optional(),
smsNotifications: z.boolean().optional(),
pushNotifications: z.boolean().optional(),
favoriteBranchId: uuidSchema.optional(),
notes: z.string().max(1000).optional(),
});
// List memberships query schema
export const listMembershipsQuerySchema = paginationSchema.extend({
programId: uuidSchema.optional(),
levelId: uuidSchema.optional(),
status: membershipStatusEnum.optional(),
customerId: uuidSchema.optional(),
});
// ==================== TRANSACTION SCHEMAS ====================
// Category amount schema (for multipliers)
export const categoryAmountSchema = z.object({
categoryId: uuidSchema,
amount: moneySchema.positive(),
});
// Earn points schema
export const earnPointsSchema = z.object({
membershipId: uuidSchema,
purchaseAmount: moneySchema.positive('Purchase amount must be positive'),
orderId: uuidSchema.optional(),
orderNumber: z.string().max(30).optional(),
branchId: uuidSchema.optional(),
categoryMultipliers: z.array(categoryAmountSchema).optional(),
});
// Redeem points schema
export const redeemPointsSchema = z.object({
membershipId: uuidSchema,
points: z.number().int().positive('Points must be positive'),
orderId: uuidSchema.optional(),
orderNumber: z.string().max(30).optional(),
branchId: uuidSchema.optional(),
description: z.string().max(255).optional(),
});
// Adjust points schema
export const adjustPointsSchema = z.object({
membershipId: uuidSchema,
points: z.number().int().refine(v => v !== 0, 'Points cannot be zero'),
reason: z.string().min(1, 'Reason is required').max(255),
});
// List transactions query schema
export const listTransactionsQuerySchema = paginationSchema.extend({
membershipId: uuidSchema.optional(),
type: transactionTypeEnum.optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
});
// ==================== LOOKUP SCHEMAS ====================
// Lookup by card number
export const lookupByCardSchema = z.object({
cardNumber: z.string().min(10).max(20),
});
// Calculate points preview schema
export const calculatePointsSchema = z.object({
membershipId: uuidSchema,
purchaseAmount: moneySchema.positive(),
categoryMultipliers: z.array(categoryAmountSchema).optional(),
});
// Types
export type CreateProgramInput = z.infer<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>;

View File

@ -0,0 +1,156 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Cart } from './cart.entity';
@Entity('cart_items', { schema: 'retail' })
@Index(['tenantId', 'cartId'])
@Index(['tenantId', 'productId'])
@Index(['tenantId', 'cartId', 'productId', 'variantId'], { unique: true })
export class CartItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'cart_id', type: 'uuid' })
cartId: string;
// Product (from erp-core)
@Column({ name: 'product_id', type: 'uuid' })
productId: string;
@Column({ name: 'product_code', length: 50 })
productCode: string;
@Column({ name: 'product_name', length: 200 })
productName: string;
@Column({ name: 'product_slug', length: 255, nullable: true })
productSlug: string;
@Column({ name: 'product_image', length: 255, nullable: true })
productImage: string;
// Variant
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
variantId: string;
@Column({ name: 'variant_name', length: 200, nullable: true })
variantName: string;
@Column({ name: 'variant_sku', length: 50, nullable: true })
variantSku: string;
// Variant options
@Column({ name: 'variant_options', type: 'jsonb', nullable: true })
variantOptions: {
name: string;
value: string;
}[];
// Quantity
@Column({ type: 'decimal', precision: 15, scale: 4, default: 1 })
quantity: number;
// Pricing
@Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 })
unitPrice: number;
@Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 })
originalPrice: number;
@Column({ name: 'price_list_id', type: 'uuid', nullable: true })
priceListId: string;
// Discounts
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPercent: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
discountAmount: number;
@Column({ name: 'promotion_id', type: 'uuid', nullable: true })
promotionId: string;
@Column({ name: 'promotion_name', length: 100, nullable: true })
promotionName: string;
// Tax
@Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 })
taxRate: number;
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
taxAmount: number;
@Column({ name: 'tax_included', type: 'boolean', default: true })
taxIncluded: boolean;
// Totals
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
subtotal: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
total: number;
// Stock check
@Column({ name: 'stock_available', type: 'decimal', precision: 15, scale: 4, nullable: true })
stockAvailable: number;
@Column({ name: 'is_available', type: 'boolean', default: true })
isAvailable: boolean;
@Column({ name: 'availability_message', length: 255, nullable: true })
availabilityMessage: string;
// Warehouse
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId: string;
// Gift options
@Column({ name: 'is_gift', type: 'boolean', default: false })
isGift: boolean;
@Column({ name: 'gift_message', type: 'text', nullable: true })
giftMessage: string;
// Custom options (personalization, etc.)
@Column({ name: 'custom_options', type: 'jsonb', nullable: true })
customOptions: {
name: string;
value: string;
price?: number;
}[];
// Saved for later
@Column({ name: 'saved_for_later', type: 'boolean', default: false })
savedForLater: boolean;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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;
}

View File

@ -0,0 +1,210 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { CartItem } from './cart-item.entity';
export enum CartStatus {
ACTIVE = 'active',
ABANDONED = 'abandoned',
CONVERTED = 'converted',
MERGED = 'merged',
EXPIRED = 'expired',
}
@Entity('carts', { schema: 'retail' })
@Index(['tenantId', 'customerId'])
@Index(['tenantId', 'sessionId'])
@Index(['tenantId', 'status', 'updatedAt'])
export class Cart {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
// Customer (if logged in, from erp-core partners)
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId: string;
// Session (for guest carts)
@Column({ name: 'session_id', length: 100, nullable: true })
sessionId: string;
@Column({
type: 'enum',
enum: CartStatus,
default: CartStatus.ACTIVE,
})
status: CartStatus;
// Currency
@Column({ name: 'currency_code', length: 3, default: 'MXN' })
currencyCode: string;
// Totals
@Column({ name: 'items_count', type: 'int', default: 0 })
itemsCount: number;
@Column({ name: 'items_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 })
itemsQuantity: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
subtotal: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
discountAmount: number;
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
taxAmount: number;
@Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
shippingAmount: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
total: number;
// Coupon
@Column({ name: 'coupon_id', type: 'uuid', nullable: true })
couponId: string;
@Column({ name: 'coupon_code', length: 50, nullable: true })
couponCode: string;
@Column({ name: 'coupon_discount', type: 'decimal', precision: 15, scale: 2, default: 0 })
couponDiscount: number;
// Loyalty points
@Column({ name: 'loyalty_points_to_use', type: 'int', default: 0 })
loyaltyPointsToUse: number;
@Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
loyaltyPointsValue: number;
// Shipping
@Column({ name: 'shipping_method_id', type: 'uuid', nullable: true })
shippingMethodId: string;
@Column({ name: 'shipping_method_name', length: 100, nullable: true })
shippingMethodName: string;
// Shipping address
@Column({ name: 'shipping_address', type: 'jsonb', nullable: true })
shippingAddress: {
firstName: string;
lastName: string;
company?: string;
address1: string;
address2?: string;
city: string;
state: string;
postalCode: string;
country: string;
phone: string;
instructions?: string;
};
// Billing address
@Column({ name: 'billing_address', type: 'jsonb', nullable: true })
billingAddress: {
firstName: string;
lastName: string;
company?: string;
rfc?: string;
address1: string;
address2?: string;
city: string;
state: string;
postalCode: string;
country: string;
phone: string;
};
@Column({ name: 'billing_same_as_shipping', type: 'boolean', default: true })
billingSameAsShipping: boolean;
// Invoice request
@Column({ name: 'requires_invoice', type: 'boolean', default: false })
requiresInvoice: boolean;
@Column({ name: 'invoice_rfc', length: 13, nullable: true })
invoiceRfc: string;
@Column({ name: 'invoice_uso_cfdi', length: 4, nullable: true })
invoiceUsoCfdi: string;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
// Gift
@Column({ name: 'is_gift', type: 'boolean', default: false })
isGift: boolean;
@Column({ name: 'gift_message', type: 'text', nullable: true })
giftMessage: string;
// Abandoned cart recovery
@Column({ name: 'recovery_email_sent', type: 'boolean', default: false })
recoveryEmailSent: boolean;
@Column({ name: 'recovery_email_sent_at', type: 'timestamp with time zone', nullable: true })
recoveryEmailSentAt: Date;
// Conversion tracking
@Column({ name: 'converted_to_order_id', type: 'uuid', nullable: true })
convertedToOrderId: string;
@Column({ name: 'converted_at', type: 'timestamp with time zone', nullable: true })
convertedAt: Date;
// Merge tracking
@Column({ name: 'merged_into_cart_id', type: 'uuid', nullable: true })
mergedIntoCartId: string;
// Analytics
@Column({ name: 'utm_source', length: 100, nullable: true })
utmSource: string;
@Column({ name: 'utm_medium', length: 100, nullable: true })
utmMedium: string;
@Column({ name: 'utm_campaign', length: 100, nullable: true })
utmCampaign: string;
@Column({ name: 'referrer_url', length: 255, nullable: true })
referrerUrl: string;
// Device info
@Column({ name: 'device_type', length: 20, nullable: true })
deviceType: 'desktop' | 'tablet' | 'mobile';
@Column({ name: 'ip_address', length: 45, nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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[];
}

View File

@ -0,0 +1,198 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { EcommerceOrder } from './ecommerce-order.entity';
export enum OrderLineStatus {
PENDING = 'pending',
RESERVED = 'reserved',
FULFILLED = 'fulfilled',
SHIPPED = 'shipped',
DELIVERED = 'delivered',
CANCELLED = 'cancelled',
RETURNED = 'returned',
REFUNDED = 'refunded',
}
@Entity('ecommerce_order_lines', { schema: 'retail' })
@Index(['tenantId', 'orderId'])
@Index(['tenantId', 'productId'])
export class EcommerceOrderLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'order_id', type: 'uuid' })
orderId: string;
@Column({ name: 'line_number', type: 'int' })
lineNumber: number;
@Column({
type: 'enum',
enum: OrderLineStatus,
default: OrderLineStatus.PENDING,
})
status: OrderLineStatus;
// Product (from erp-core)
@Column({ name: 'product_id', type: 'uuid' })
productId: string;
@Column({ name: 'product_code', length: 50 })
productCode: string;
@Column({ name: 'product_name', length: 200 })
productName: string;
@Column({ name: 'product_slug', length: 255, nullable: true })
productSlug: string;
@Column({ name: 'product_image', length: 255, nullable: true })
productImage: string;
// Variant
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
variantId: string;
@Column({ name: 'variant_name', length: 200, nullable: true })
variantName: string;
@Column({ name: 'variant_sku', length: 50, nullable: true })
variantSku: string;
@Column({ name: 'variant_options', type: 'jsonb', nullable: true })
variantOptions: {
name: string;
value: string;
}[];
// Quantity
@Column({ type: 'decimal', precision: 15, scale: 4, default: 1 })
quantity: number;
@Column({ name: 'quantity_fulfilled', type: 'decimal', precision: 15, scale: 4, default: 0 })
quantityFulfilled: number;
@Column({ name: 'quantity_refunded', type: 'decimal', precision: 15, scale: 4, default: 0 })
quantityRefunded: number;
// UOM
@Column({ name: 'uom_id', type: 'uuid', nullable: true })
uomId: string;
@Column({ name: 'uom_name', length: 20, default: 'PZA' })
uomName: string;
// Pricing
@Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 })
unitPrice: number;
@Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 })
originalPrice: number;
// Discounts
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPercent: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
discountAmount: number;
@Column({ name: 'promotion_id', type: 'uuid', nullable: true })
promotionId: string;
@Column({ name: 'promotion_name', length: 100, nullable: true })
promotionName: string;
// Tax
@Column({ name: 'tax_id', type: 'uuid', nullable: true })
taxId: string;
@Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 })
taxRate: number;
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
taxAmount: number;
@Column({ name: 'tax_included', type: 'boolean', default: true })
taxIncluded: boolean;
// Totals
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
subtotal: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
total: number;
// Cost
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true })
unitCost: number;
// Weight (for shipping calculation)
@Column({ type: 'decimal', precision: 10, scale: 4, nullable: true })
weight: number;
@Column({ name: 'weight_unit', length: 5, default: 'kg' })
weightUnit: string;
// Warehouse
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId: string;
// Lot/Serial
@Column({ name: 'lot_number', length: 50, nullable: true })
lotNumber: string;
@Column({ name: 'serial_number', length: 50, nullable: true })
serialNumber: string;
// Custom options
@Column({ name: 'custom_options', type: 'jsonb', nullable: true })
customOptions: {
name: string;
value: string;
price?: number;
}[];
// Gift
@Column({ name: 'is_gift', type: 'boolean', default: false })
isGift: boolean;
@Column({ name: 'gift_message', type: 'text', nullable: true })
giftMessage: string;
// Refund
@Column({ name: 'refunded_at', type: 'timestamp with time zone', nullable: true })
refundedAt: Date;
@Column({ name: 'refund_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
refundAmount: number;
@Column({ name: 'refund_reason', length: 255, nullable: true })
refundReason: string;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => EcommerceOrder, (order) => order.lines)
@JoinColumn({ name: 'order_id' })
order: EcommerceOrder;
}

View File

@ -0,0 +1,309 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { EcommerceOrderLine } from './ecommerce-order-line.entity';
export enum EcommerceOrderStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
PROCESSING = 'processing',
READY_FOR_PICKUP = 'ready_for_pickup',
SHIPPED = 'shipped',
DELIVERED = 'delivered',
CANCELLED = 'cancelled',
REFUNDED = 'refunded',
}
export enum PaymentStatus {
PENDING = 'pending',
AUTHORIZED = 'authorized',
CAPTURED = 'captured',
FAILED = 'failed',
REFUNDED = 'refunded',
PARTIALLY_REFUNDED = 'partially_refunded',
}
export enum FulfillmentType {
SHIP = 'ship',
PICKUP = 'pickup',
DELIVERY = 'delivery',
}
@Entity('ecommerce_orders', { schema: 'retail' })
@Index(['tenantId', 'status', 'createdAt'])
@Index(['tenantId', 'customerId'])
@Index(['tenantId', 'number'], { unique: true })
@Index(['tenantId', 'paymentStatus'])
export class EcommerceOrder {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ length: 30, unique: true })
number: string;
@Column({
type: 'enum',
enum: EcommerceOrderStatus,
default: EcommerceOrderStatus.PENDING,
})
status: EcommerceOrderStatus;
@Column({
name: 'payment_status',
type: 'enum',
enum: PaymentStatus,
default: PaymentStatus.PENDING,
})
paymentStatus: PaymentStatus;
@Column({
name: 'fulfillment_type',
type: 'enum',
enum: FulfillmentType,
default: FulfillmentType.SHIP,
})
fulfillmentType: FulfillmentType;
// Customer
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId: string;
@Column({ name: 'customer_email', length: 100 })
customerEmail: string;
@Column({ name: 'customer_phone', length: 20, nullable: true })
customerPhone: string;
@Column({ name: 'customer_name', length: 200 })
customerName: string;
@Column({ name: 'is_guest', type: 'boolean', default: false })
isGuest: boolean;
// Currency
@Column({ name: 'currency_code', length: 3, default: 'MXN' })
currencyCode: string;
@Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 })
exchangeRate: number;
// Amounts
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
subtotal: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
discountAmount: number;
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
taxAmount: number;
@Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
shippingAmount: number;
@Column({ name: 'shipping_tax', type: 'decimal', precision: 15, scale: 2, default: 0 })
shippingTax: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
total: number;
@Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 })
amountPaid: number;
@Column({ name: 'amount_refunded', type: 'decimal', precision: 15, scale: 2, default: 0 })
amountRefunded: number;
// Item counts
@Column({ name: 'items_count', type: 'int', default: 0 })
itemsCount: number;
@Column({ name: 'items_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 })
itemsQuantity: number;
// Coupon
@Column({ name: 'coupon_id', type: 'uuid', nullable: true })
couponId: string;
@Column({ name: 'coupon_code', length: 50, nullable: true })
couponCode: string;
@Column({ name: 'coupon_discount', type: 'decimal', precision: 15, scale: 2, default: 0 })
couponDiscount: number;
// Loyalty
@Column({ name: 'loyalty_points_used', type: 'int', default: 0 })
loyaltyPointsUsed: number;
@Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
loyaltyPointsValue: number;
@Column({ name: 'loyalty_points_earned', type: 'int', default: 0 })
loyaltyPointsEarned: number;
// Shipping address
@Column({ name: 'shipping_address', type: 'jsonb' })
shippingAddress: {
firstName: string;
lastName: string;
company?: string;
address1: string;
address2?: string;
city: string;
state: string;
postalCode: string;
country: string;
phone: string;
instructions?: string;
};
// Billing address
@Column({ name: 'billing_address', type: 'jsonb' })
billingAddress: {
firstName: string;
lastName: string;
company?: string;
rfc?: string;
address1: string;
address2?: string;
city: string;
state: string;
postalCode: string;
country: string;
phone: string;
};
// Shipping
@Column({ name: 'shipping_method_id', type: 'uuid', nullable: true })
shippingMethodId: string;
@Column({ name: 'shipping_method_name', length: 100, nullable: true })
shippingMethodName: string;
@Column({ name: 'shipping_carrier', length: 100, nullable: true })
shippingCarrier: string;
@Column({ name: 'tracking_number', length: 100, nullable: true })
trackingNumber: string;
@Column({ name: 'tracking_url', length: 255, nullable: true })
trackingUrl: string;
@Column({ name: 'estimated_delivery', type: 'date', nullable: true })
estimatedDelivery: Date;
@Column({ name: 'shipped_at', type: 'timestamp with time zone', nullable: true })
shippedAt: Date;
@Column({ name: 'delivered_at', type: 'timestamp with time zone', nullable: true })
deliveredAt: Date;
// Pickup
@Column({ name: 'pickup_branch_id', type: 'uuid', nullable: true })
pickupBranchId: string;
@Column({ name: 'pickup_ready_at', type: 'timestamp with time zone', nullable: true })
pickupReadyAt: Date;
@Column({ name: 'picked_up_at', type: 'timestamp with time zone', nullable: true })
pickedUpAt: Date;
// Payment
@Column({ name: 'payment_method', length: 50, nullable: true })
paymentMethod: string;
@Column({ name: 'payment_gateway', length: 50, nullable: true })
paymentGateway: string;
@Column({ name: 'payment_transaction_id', length: 100, nullable: true })
paymentTransactionId: string;
@Column({ name: 'payment_details', type: 'jsonb', nullable: true })
paymentDetails: Record<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[];
}

View File

@ -0,0 +1,5 @@
export * from './cart.entity';
export * from './cart-item.entity';
export * from './ecommerce-order.entity';
export * from './ecommerce-order-line.entity';
export * from './shipping-rate.entity';

View File

@ -0,0 +1,182 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum ShippingRateStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
}
export enum ShippingCalculation {
FLAT_RATE = 'flat_rate',
BY_WEIGHT = 'by_weight',
BY_PRICE = 'by_price',
BY_QUANTITY = 'by_quantity',
FREE = 'free',
CARRIER_CALCULATED = 'carrier_calculated',
}
@Entity('shipping_rates', { schema: 'retail' })
@Index(['tenantId', 'status'])
@Index(['tenantId', 'code'], { unique: true })
export class ShippingRate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ length: 20, unique: true })
code: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({
type: 'enum',
enum: ShippingRateStatus,
default: ShippingRateStatus.ACTIVE,
})
status: ShippingRateStatus;
@Column({
name: 'calculation_type',
type: 'enum',
enum: ShippingCalculation,
default: ShippingCalculation.FLAT_RATE,
})
calculationType: ShippingCalculation;
// Flat rate
@Column({ name: 'flat_rate', type: 'decimal', precision: 15, scale: 2, nullable: true })
flatRate: number;
// Weight-based rates
@Column({ name: 'weight_rates', type: 'jsonb', nullable: true })
weightRates: {
minWeight: number;
maxWeight: number;
rate: number;
}[];
@Column({ name: 'weight_unit', length: 5, default: 'kg' })
weightUnit: string;
// Price-based rates
@Column({ name: 'price_rates', type: 'jsonb', nullable: true })
priceRates: {
minPrice: number;
maxPrice: number;
rate: number;
}[];
// Quantity-based rates
@Column({ name: 'quantity_rates', type: 'jsonb', nullable: true })
quantityRates: {
minQuantity: number;
maxQuantity: number;
rate: number;
}[];
// Free shipping threshold
@Column({ name: 'free_shipping_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true })
freeShippingThreshold: number;
// Carrier integration
@Column({ name: 'carrier_code', length: 50, nullable: true })
carrierCode: string;
@Column({ name: 'carrier_name', length: 100, nullable: true })
carrierName: string;
@Column({ name: 'carrier_service', length: 50, nullable: true })
carrierService: string;
@Column({ name: 'carrier_api_config', type: 'jsonb', nullable: true })
carrierApiConfig: Record<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;
}

View File

@ -0,0 +1,401 @@
import { Request, Response } from 'express';
import { BaseController } from '../../../shared/controllers/base.controller';
import { StockTransferService, TransferQueryOptions } from '../services/stock-transfer.service';
import { StockAdjustmentService, AdjustmentQueryOptions } from '../services/stock-adjustment.service';
export class InventoryController extends BaseController {
constructor(
private readonly transferService: StockTransferService,
private readonly adjustmentService: StockAdjustmentService
) {
super();
}
// ==================== TRANSFER ENDPOINTS ====================
/**
* Create a stock transfer
* POST /inventory/transfers
*/
createTransfer = async (req: Request, res: Response): Promise<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);
};
}

View File

@ -0,0 +1,4 @@
export * from './stock-transfer.entity';
export * from './stock-transfer-line.entity';
export * from './stock-adjustment.entity';
export * from './stock-adjustment-line.entity';

View File

@ -0,0 +1,120 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { StockAdjustment } from './stock-adjustment.entity';
export enum AdjustmentDirection {
INCREASE = 'increase',
DECREASE = 'decrease',
}
@Entity('stock_adjustment_lines', { schema: 'retail' })
@Index(['tenantId', 'adjustmentId'])
@Index(['tenantId', 'productId'])
export class StockAdjustmentLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'adjustment_id', type: 'uuid' })
adjustmentId: string;
@Column({ name: 'line_number', type: 'int' })
lineNumber: number;
// Product (from erp-core)
@Column({ name: 'product_id', type: 'uuid' })
productId: string;
@Column({ name: 'product_code', length: 50 })
productCode: string;
@Column({ name: 'product_name', length: 200 })
productName: string;
@Column({ name: 'product_barcode', length: 50, nullable: true })
productBarcode: string;
// Variant
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
variantId: string;
@Column({ name: 'variant_name', length: 200, nullable: true })
variantName: string;
// Unit of measure
@Column({ name: 'uom_id', type: 'uuid', nullable: true })
uomId: string;
@Column({ name: 'uom_name', length: 20, default: 'PZA' })
uomName: string;
// Stock quantities
@Column({ name: 'system_quantity', type: 'decimal', precision: 15, scale: 4 })
systemQuantity: number;
@Column({ name: 'counted_quantity', type: 'decimal', precision: 15, scale: 4 })
countedQuantity: number;
@Column({ name: 'adjustment_quantity', type: 'decimal', precision: 15, scale: 4 })
adjustmentQuantity: number;
@Column({
type: 'enum',
enum: AdjustmentDirection,
})
direction: AdjustmentDirection;
// Costs
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 })
unitCost: number;
@Column({ name: 'adjustment_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
adjustmentValue: number;
// Location
@Column({ name: 'location_id', type: 'uuid', nullable: true })
locationId: string;
@Column({ name: 'location_code', length: 50, nullable: true })
locationCode: string;
// Lot/Serial tracking
@Column({ name: 'lot_number', length: 50, nullable: true })
lotNumber: string;
@Column({ name: 'serial_number', length: 50, nullable: true })
serialNumber: string;
@Column({ name: 'expiry_date', type: 'date', nullable: true })
expiryDate: Date;
// Reason (line-level)
@Column({ name: 'line_reason', length: 255, nullable: true })
lineReason: string;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => StockAdjustment, (adjustment) => adjustment.lines)
@JoinColumn({ name: 'adjustment_id' })
adjustment: StockAdjustment;
}

View File

@ -0,0 +1,169 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { StockAdjustmentLine } from './stock-adjustment-line.entity';
export enum AdjustmentStatus {
DRAFT = 'draft',
PENDING_APPROVAL = 'pending_approval',
APPROVED = 'approved',
POSTED = 'posted',
REJECTED = 'rejected',
CANCELLED = 'cancelled',
}
export enum AdjustmentType {
INVENTORY_COUNT = 'inventory_count',
DAMAGE = 'damage',
THEFT = 'theft',
EXPIRY = 'expiry',
CORRECTION = 'correction',
INITIAL_STOCK = 'initial_stock',
PRODUCTION = 'production',
OTHER = 'other',
}
@Entity('stock_adjustments', { schema: 'retail' })
@Index(['tenantId', 'branchId', 'status'])
@Index(['tenantId', 'adjustmentDate'])
@Index(['tenantId', 'number'], { unique: true })
export class StockAdjustment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@Column({ name: 'warehouse_id', type: 'uuid' })
warehouseId: string;
@Column({ name: 'location_id', type: 'uuid', nullable: true })
locationId: string;
@Column({ length: 30, unique: true })
number: string;
@Column({
type: 'enum',
enum: AdjustmentType,
default: AdjustmentType.INVENTORY_COUNT,
})
type: AdjustmentType;
@Column({
type: 'enum',
enum: AdjustmentStatus,
default: AdjustmentStatus.DRAFT,
})
status: AdjustmentStatus;
@Column({ name: 'adjustment_date', type: 'date' })
adjustmentDate: Date;
// Count info (for inventory counts)
@Column({ name: 'is_full_count', type: 'boolean', default: false })
isFullCount: boolean;
@Column({ name: 'count_category_id', type: 'uuid', nullable: true })
countCategoryId: string;
// Summary
@Column({ name: 'lines_count', type: 'int', default: 0 })
linesCount: number;
@Column({ name: 'total_increase_qty', type: 'decimal', precision: 15, scale: 4, default: 0 })
totalIncreaseQty: number;
@Column({ name: 'total_decrease_qty', type: 'decimal', precision: 15, scale: 4, default: 0 })
totalDecreaseQty: number;
@Column({ name: 'total_increase_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalIncreaseValue: number;
@Column({ name: 'total_decrease_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalDecreaseValue: number;
@Column({ name: 'net_adjustment_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
netAdjustmentValue: number;
// Financial account (for accounting integration)
@Column({ name: 'account_id', type: 'uuid', nullable: true })
accountId: string;
@Column({ name: 'counterpart_account_id', type: 'uuid', nullable: true })
counterpartAccountId: string;
// Approval workflow
@Column({ name: 'requires_approval', type: 'boolean', default: true })
requiresApproval: boolean;
@Column({ name: 'approval_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true })
approvalThreshold: number;
// Users
@Column({ name: 'created_by', type: 'uuid' })
createdBy: string;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedBy: string;
@Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true })
approvedAt: Date;
@Column({ name: 'posted_by', type: 'uuid', nullable: true })
postedBy: string;
@Column({ name: 'posted_at', type: 'timestamp with time zone', nullable: true })
postedAt: Date;
@Column({ name: 'rejected_by', type: 'uuid', nullable: true })
rejectedBy: string;
@Column({ name: 'rejected_at', type: 'timestamp with time zone', nullable: true })
rejectedAt: Date;
@Column({ name: 'rejection_reason', length: 255, nullable: true })
rejectionReason: string;
// Reason/Description
@Column({ type: 'text' })
reason: string;
// Reference
@Column({ name: 'reference_type', length: 50, nullable: true })
referenceType: string;
@Column({ name: 'reference_id', type: 'uuid', nullable: true })
referenceId: string;
@Column({ name: 'reference_number', length: 50, nullable: true })
referenceNumber: string;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => StockAdjustmentLine, (line) => line.adjustment)
lines: StockAdjustmentLine[];
}

View File

@ -0,0 +1,144 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { StockTransfer } from './stock-transfer.entity';
export enum TransferLineStatus {
PENDING = 'pending',
PARTIALLY_SHIPPED = 'partially_shipped',
SHIPPED = 'shipped',
PARTIALLY_RECEIVED = 'partially_received',
RECEIVED = 'received',
CANCELLED = 'cancelled',
}
@Entity('stock_transfer_lines', { schema: 'retail' })
@Index(['tenantId', 'transferId'])
@Index(['tenantId', 'productId'])
export class StockTransferLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'transfer_id', type: 'uuid' })
transferId: string;
@Column({ name: 'line_number', type: 'int' })
lineNumber: number;
// Product (from erp-core)
@Column({ name: 'product_id', type: 'uuid' })
productId: string;
@Column({ name: 'product_code', length: 50 })
productCode: string;
@Column({ name: 'product_name', length: 200 })
productName: string;
@Column({ name: 'product_barcode', length: 50, nullable: true })
productBarcode: string;
// Variant
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
variantId: string;
@Column({ name: 'variant_name', length: 200, nullable: true })
variantName: string;
// Unit of measure
@Column({ name: 'uom_id', type: 'uuid', nullable: true })
uomId: string;
@Column({ name: 'uom_name', length: 20, default: 'PZA' })
uomName: string;
// Quantities
@Column({ name: 'quantity_requested', type: 'decimal', precision: 15, scale: 4 })
quantityRequested: number;
@Column({ name: 'quantity_approved', type: 'decimal', precision: 15, scale: 4, nullable: true })
quantityApproved: number;
@Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 })
quantityShipped: number;
@Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 })
quantityReceived: number;
@Column({ name: 'quantity_difference', type: 'decimal', precision: 15, scale: 4, default: 0 })
quantityDifference: number;
// Costs
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 })
unitCost: number;
@Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalCost: number;
// Stock info at time of request
@Column({ name: 'source_stock_before', type: 'decimal', precision: 15, scale: 4, nullable: true })
sourceStockBefore: number;
@Column({ name: 'dest_stock_before', type: 'decimal', precision: 15, scale: 4, nullable: true })
destStockBefore: number;
// Lot/Serial tracking
@Column({ name: 'lot_number', length: 50, nullable: true })
lotNumber: string;
@Column({ name: 'serial_numbers', type: 'jsonb', nullable: true })
serialNumbers: string[];
@Column({ name: 'expiry_date', type: 'date', nullable: true })
expiryDate: Date;
// Status
@Column({
type: 'enum',
enum: TransferLineStatus,
default: TransferLineStatus.PENDING,
})
status: TransferLineStatus;
// Receiving details
@Column({ name: 'received_date', type: 'timestamp with time zone', nullable: true })
receivedDate: Date;
@Column({ name: 'received_by', type: 'uuid', nullable: true })
receivedBy: string;
@Column({ name: 'receiving_notes', length: 255, nullable: true })
receivingNotes: string;
@Column({ name: 'damage_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 })
damageQuantity: number;
@Column({ name: 'damage_reason', length: 255, nullable: true })
damageReason: string;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => StockTransfer, (transfer) => transfer.lines)
@JoinColumn({ name: 'transfer_id' })
transfer: StockTransfer;
}

View File

@ -0,0 +1,173 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { StockTransferLine } from './stock-transfer-line.entity';
export enum TransferStatus {
DRAFT = 'draft',
PENDING_APPROVAL = 'pending_approval',
APPROVED = 'approved',
IN_TRANSIT = 'in_transit',
PARTIALLY_RECEIVED = 'partially_received',
RECEIVED = 'received',
CANCELLED = 'cancelled',
}
export enum TransferType {
BRANCH_TO_BRANCH = 'branch_to_branch',
WAREHOUSE_TO_BRANCH = 'warehouse_to_branch',
BRANCH_TO_WAREHOUSE = 'branch_to_warehouse',
INTERNAL = 'internal',
}
@Entity('stock_transfers', { schema: 'retail' })
@Index(['tenantId', 'status', 'createdAt'])
@Index(['tenantId', 'sourceBranchId'])
@Index(['tenantId', 'destBranchId'])
@Index(['tenantId', 'number'], { unique: true })
export class StockTransfer {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ length: 30, unique: true })
number: string;
@Column({
type: 'enum',
enum: TransferType,
default: TransferType.BRANCH_TO_BRANCH,
})
type: TransferType;
@Column({
type: 'enum',
enum: TransferStatus,
default: TransferStatus.DRAFT,
})
status: TransferStatus;
// Source
@Column({ name: 'source_branch_id', type: 'uuid', nullable: true })
sourceBranchId: string;
@Column({ name: 'source_warehouse_id', type: 'uuid' })
sourceWarehouseId: string;
@Column({ name: 'source_location_id', type: 'uuid', nullable: true })
sourceLocationId: string;
// Destination
@Column({ name: 'dest_branch_id', type: 'uuid', nullable: true })
destBranchId: string;
@Column({ name: 'dest_warehouse_id', type: 'uuid' })
destWarehouseId: string;
@Column({ name: 'dest_location_id', type: 'uuid', nullable: true })
destLocationId: string;
// Dates
@Column({ name: 'requested_date', type: 'date' })
requestedDate: Date;
@Column({ name: 'expected_date', type: 'date', nullable: true })
expectedDate: Date;
@Column({ name: 'shipped_date', type: 'timestamp with time zone', nullable: true })
shippedDate: Date;
@Column({ name: 'received_date', type: 'timestamp with time zone', nullable: true })
receivedDate: Date;
// Line counts
@Column({ name: 'lines_count', type: 'int', default: 0 })
linesCount: number;
@Column({ name: 'items_requested', type: 'decimal', precision: 15, scale: 4, default: 0 })
itemsRequested: number;
@Column({ name: 'items_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 })
itemsShipped: number;
@Column({ name: 'items_received', type: 'decimal', precision: 15, scale: 4, default: 0 })
itemsReceived: number;
// Totals (for reporting)
@Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalCost: number;
@Column({ name: 'total_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalValue: number;
// Shipping info
@Column({ name: 'shipping_method', length: 50, nullable: true })
shippingMethod: string;
@Column({ name: 'tracking_number', length: 100, nullable: true })
trackingNumber: string;
@Column({ name: 'carrier_name', length: 100, nullable: true })
carrierName: string;
// Users
@Column({ name: 'requested_by', type: 'uuid' })
requestedBy: string;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedBy: string;
@Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true })
approvedAt: Date;
@Column({ name: 'shipped_by', type: 'uuid', nullable: true })
shippedBy: string;
@Column({ name: 'received_by', type: 'uuid', nullable: true })
receivedBy: string;
// Priority
@Column({ type: 'int', default: 0 })
priority: number; // 0=normal, 1=high, 2=urgent
// Reason
@Column({ type: 'text', nullable: true })
reason: string;
// Reference (for transfers generated from sales, etc.)
@Column({ name: 'reference_type', length: 50, nullable: true })
referenceType: string;
@Column({ name: 'reference_id', type: 'uuid', nullable: true })
referenceId: string;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'receiving_notes', type: 'text', nullable: true })
receivingNotes: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => StockTransferLine, (line) => line.transfer)
lines: StockTransferLine[];
}

View File

@ -0,0 +1,5 @@
export * from './entities';
export * from './services';
export * from './controllers/inventory.controller';
export * from './routes/inventory.routes';
export * from './validation/inventory.schema';

View File

@ -0,0 +1,179 @@
import { Router } from 'express';
import { InventoryController } from '../controllers/inventory.controller';
import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware';
import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware';
import {
createTransferSchema,
shipTransferSchema,
receiveTransferSchema,
cancelTransferSchema,
listTransfersQuerySchema,
createAdjustmentSchema,
addAdjustmentLineSchema,
rejectAdjustmentSchema,
cancelAdjustmentSchema,
listAdjustmentsQuerySchema,
} from '../validation/inventory.schema';
export function createInventoryRoutes(controller: InventoryController): Router {
const router = Router();
// Apply auth middleware to all routes
router.use(requireAuth);
// ==================== TRANSFER ROUTES ====================
// Create transfer
router.post(
'/transfers',
requirePermissions(['inventory.transfers.create']),
validateBody(createTransferSchema),
controller.createTransfer
);
// List transfers
router.get(
'/transfers',
requirePermissions(['inventory.transfers.view']),
validateQuery(listTransfersQuerySchema),
controller.listTransfers
);
// Get transfer summary
router.get(
'/transfers/summary',
requirePermissions(['inventory.transfers.view']),
controller.getTransferSummary
);
// Get incoming transfers
router.get(
'/transfers/incoming',
requirePermissions(['inventory.transfers.view']),
controller.getIncomingTransfers
);
// Get transfer by ID
router.get(
'/transfers/:id',
requirePermissions(['inventory.transfers.view']),
controller.getTransfer
);
// Submit transfer for approval
router.post(
'/transfers/:id/submit',
requirePermissions(['inventory.transfers.submit']),
controller.submitTransfer
);
// Approve transfer
router.post(
'/transfers/:id/approve',
requireRoles(['admin', 'warehouse_manager', 'manager']),
controller.approveTransfer
);
// Ship transfer
router.post(
'/transfers/:id/ship',
requirePermissions(['inventory.transfers.ship']),
validateBody(shipTransferSchema),
controller.shipTransfer
);
// Receive transfer
router.post(
'/transfers/:id/receive',
requirePermissions(['inventory.transfers.receive']),
validateBody(receiveTransferSchema),
controller.receiveTransfer
);
// Cancel transfer
router.post(
'/transfers/:id/cancel',
requirePermissions(['inventory.transfers.cancel']),
validateBody(cancelTransferSchema),
controller.cancelTransfer
);
// ==================== ADJUSTMENT ROUTES ====================
// Create adjustment
router.post(
'/adjustments',
requirePermissions(['inventory.adjustments.create']),
validateBody(createAdjustmentSchema),
controller.createAdjustment
);
// List adjustments
router.get(
'/adjustments',
requirePermissions(['inventory.adjustments.view']),
validateQuery(listAdjustmentsQuerySchema),
controller.listAdjustments
);
// Get adjustment summary
router.get(
'/adjustments/summary',
requirePermissions(['inventory.adjustments.view']),
controller.getAdjustmentSummary
);
// Get adjustment by ID
router.get(
'/adjustments/:id',
requirePermissions(['inventory.adjustments.view']),
controller.getAdjustment
);
// Add line to adjustment
router.post(
'/adjustments/:id/lines',
requirePermissions(['inventory.adjustments.edit']),
validateBody(addAdjustmentLineSchema),
controller.addAdjustmentLine
);
// Submit adjustment for approval
router.post(
'/adjustments/:id/submit',
requirePermissions(['inventory.adjustments.submit']),
controller.submitAdjustment
);
// Approve adjustment
router.post(
'/adjustments/:id/approve',
requireRoles(['admin', 'warehouse_manager', 'manager']),
controller.approveAdjustment
);
// Reject adjustment
router.post(
'/adjustments/:id/reject',
requireRoles(['admin', 'warehouse_manager', 'manager']),
validateBody(rejectAdjustmentSchema),
controller.rejectAdjustment
);
// Post adjustment
router.post(
'/adjustments/:id/post',
requireRoles(['admin', 'warehouse_manager']),
controller.postAdjustment
);
// Cancel adjustment
router.post(
'/adjustments/:id/cancel',
requirePermissions(['inventory.adjustments.cancel']),
validateBody(cancelAdjustmentSchema),
controller.cancelAdjustment
);
return router;
}

View File

@ -0,0 +1,2 @@
export * from './stock-transfer.service';
export * from './stock-adjustment.service';

View File

@ -0,0 +1,630 @@
import { Repository, DataSource, Between } from 'typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult, QueryOptions } from '../../../shared/types';
import {
StockAdjustment,
AdjustmentStatus,
AdjustmentType,
} from '../entities/stock-adjustment.entity';
import { StockAdjustmentLine, AdjustmentDirection } from '../entities/stock-adjustment-line.entity';
export interface AdjustmentLineInput {
productId: string;
productCode: string;
productName: string;
productBarcode?: string;
variantId?: string;
variantName?: string;
uomId?: string;
uomName?: string;
systemQuantity: number;
countedQuantity: number;
unitCost?: number;
locationId?: string;
locationCode?: string;
lotNumber?: string;
serialNumber?: string;
expiryDate?: Date;
lineReason?: string;
notes?: string;
}
export interface CreateAdjustmentInput {
branchId: string;
warehouseId: string;
locationId?: string;
type: AdjustmentType;
adjustmentDate: Date;
isFullCount?: boolean;
countCategoryId?: string;
reason: string;
notes?: string;
lines: AdjustmentLineInput[];
}
export interface AdjustmentQueryOptions extends QueryOptions {
branchId?: string;
warehouseId?: string;
status?: AdjustmentStatus;
type?: AdjustmentType;
startDate?: Date;
endDate?: Date;
}
export class StockAdjustmentService extends BaseService<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,
}
);
}
}

View File

@ -0,0 +1,591 @@
import { Repository, DataSource, Between, In } from 'typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult, QueryOptions } from '../../../shared/types';
import {
StockTransfer,
TransferStatus,
TransferType,
} from '../entities/stock-transfer.entity';
import { StockTransferLine, TransferLineStatus } from '../entities/stock-transfer-line.entity';
export interface TransferLineInput {
productId: string;
productCode: string;
productName: string;
productBarcode?: string;
variantId?: string;
variantName?: string;
uomId?: string;
uomName?: string;
quantityRequested: number;
unitCost?: number;
lotNumber?: string;
serialNumbers?: string[];
expiryDate?: Date;
notes?: string;
}
export interface CreateTransferInput {
type: TransferType;
sourceBranchId?: string;
sourceWarehouseId: string;
sourceLocationId?: string;
destBranchId?: string;
destWarehouseId: string;
destLocationId?: string;
requestedDate: Date;
expectedDate?: Date;
priority?: number;
reason?: string;
notes?: string;
lines: TransferLineInput[];
}
export interface TransferQueryOptions extends QueryOptions {
branchId?: string;
sourceBranchId?: string;
destBranchId?: string;
status?: TransferStatus;
type?: TransferType;
startDate?: Date;
endDate?: Date;
}
export interface ShipLineInput {
lineId: string;
quantityShipped: number;
lotNumber?: string;
serialNumbers?: string[];
}
export interface ReceiveLineInput {
lineId: string;
quantityReceived: number;
damageQuantity?: number;
damageReason?: string;
receivingNotes?: string;
}
export class StockTransferService extends BaseService<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>;
}
}

View File

@ -0,0 +1,200 @@
import { z } from 'zod';
import {
uuidSchema,
moneySchema,
quantitySchema,
paginationSchema,
} from '../../../shared/validation/common.schema';
// Enums
export const transferTypeEnum = z.enum([
'branch_to_branch',
'warehouse_to_branch',
'branch_to_warehouse',
'internal',
]);
export const transferStatusEnum = z.enum([
'draft',
'pending_approval',
'approved',
'in_transit',
'partially_received',
'received',
'cancelled',
]);
export const adjustmentTypeEnum = z.enum([
'inventory_count',
'damage',
'theft',
'expiry',
'correction',
'initial_stock',
'production',
'other',
]);
export const adjustmentStatusEnum = z.enum([
'draft',
'pending_approval',
'approved',
'posted',
'rejected',
'cancelled',
]);
// ==================== TRANSFER SCHEMAS ====================
// Transfer line schema
export const transferLineSchema = z.object({
productId: uuidSchema,
productCode: z.string().min(1).max(50),
productName: z.string().min(1).max(200),
productBarcode: z.string().max(50).optional(),
variantId: uuidSchema.optional(),
variantName: z.string().max(200).optional(),
uomId: uuidSchema.optional(),
uomName: z.string().max(20).optional(),
quantityRequested: quantitySchema.positive('Quantity must be positive'),
unitCost: moneySchema.optional(),
lotNumber: z.string().max(50).optional(),
serialNumbers: z.array(z.string().max(50)).optional(),
expiryDate: z.coerce.date().optional(),
notes: z.string().max(500).optional(),
});
// Create transfer schema
export const createTransferSchema = z.object({
type: transferTypeEnum,
sourceBranchId: uuidSchema.optional(),
sourceWarehouseId: uuidSchema,
sourceLocationId: uuidSchema.optional(),
destBranchId: uuidSchema.optional(),
destWarehouseId: uuidSchema,
destLocationId: uuidSchema.optional(),
requestedDate: z.coerce.date(),
expectedDate: z.coerce.date().optional(),
priority: z.number().int().min(0).max(2).optional(),
reason: z.string().max(500).optional(),
notes: z.string().max(1000).optional(),
lines: z.array(transferLineSchema).min(1, 'At least one line is required'),
});
// Ship line schema
export const shipLineSchema = z.object({
lineId: uuidSchema,
quantityShipped: quantitySchema.positive('Quantity must be positive'),
lotNumber: z.string().max(50).optional(),
serialNumbers: z.array(z.string().max(50)).optional(),
});
// Ship transfer schema
export const shipTransferSchema = z.object({
lines: z.array(shipLineSchema).min(1, 'At least one line is required'),
shippingMethod: z.string().max(50).optional(),
trackingNumber: z.string().max(100).optional(),
carrierName: z.string().max(100).optional(),
});
// Receive line schema
export const receiveLineSchema = z.object({
lineId: uuidSchema,
quantityReceived: quantitySchema.min(0),
damageQuantity: quantitySchema.optional(),
damageReason: z.string().max(255).optional(),
receivingNotes: z.string().max(255).optional(),
});
// Receive transfer schema
export const receiveTransferSchema = z.object({
lines: z.array(receiveLineSchema).min(1, 'At least one line is required'),
notes: z.string().max(1000).optional(),
});
// Cancel transfer schema
export const cancelTransferSchema = z.object({
reason: z.string().min(1, 'Reason is required').max(255),
});
// List transfers query schema
export const listTransfersQuerySchema = paginationSchema.extend({
branchId: uuidSchema.optional(),
sourceBranchId: uuidSchema.optional(),
destBranchId: uuidSchema.optional(),
status: transferStatusEnum.optional(),
type: transferTypeEnum.optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
});
// ==================== ADJUSTMENT SCHEMAS ====================
// Adjustment line schema
export const adjustmentLineSchema = z.object({
productId: uuidSchema,
productCode: z.string().min(1).max(50),
productName: z.string().min(1).max(200),
productBarcode: z.string().max(50).optional(),
variantId: uuidSchema.optional(),
variantName: z.string().max(200).optional(),
uomId: uuidSchema.optional(),
uomName: z.string().max(20).optional(),
systemQuantity: quantitySchema,
countedQuantity: quantitySchema,
unitCost: moneySchema.optional(),
locationId: uuidSchema.optional(),
locationCode: z.string().max(50).optional(),
lotNumber: z.string().max(50).optional(),
serialNumber: z.string().max(50).optional(),
expiryDate: z.coerce.date().optional(),
lineReason: z.string().max(255).optional(),
notes: z.string().max(500).optional(),
});
// Create adjustment schema
export const createAdjustmentSchema = z.object({
branchId: uuidSchema,
warehouseId: uuidSchema,
locationId: uuidSchema.optional(),
type: adjustmentTypeEnum,
adjustmentDate: z.coerce.date(),
isFullCount: z.boolean().optional(),
countCategoryId: uuidSchema.optional(),
reason: z.string().min(1, 'Reason is required').max(500),
notes: z.string().max(1000).optional(),
lines: z.array(adjustmentLineSchema).min(1, 'At least one line is required'),
});
// Add adjustment line schema
export const addAdjustmentLineSchema = adjustmentLineSchema;
// Reject adjustment schema
export const rejectAdjustmentSchema = z.object({
reason: z.string().min(1, 'Rejection reason is required').max(255),
});
// Cancel adjustment schema
export const cancelAdjustmentSchema = z.object({
reason: z.string().min(1, 'Cancellation reason is required').max(255),
});
// List adjustments query schema
export const listAdjustmentsQuerySchema = paginationSchema.extend({
branchId: uuidSchema.optional(),
warehouseId: uuidSchema.optional(),
status: adjustmentStatusEnum.optional(),
type: adjustmentTypeEnum.optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
});
// Types
export type CreateTransferInput = z.infer<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>;

View File

@ -0,0 +1,742 @@
import { Request, Response, NextFunction } from 'express';
import { CFDIService } from '../services/cfdi.service';
import { CFDIBuilderService } from '../services/cfdi-builder.service';
import { AppDataSource } from '../../../config/database';
import { CFDI } from '../entities/cfdi.entity';
import { CFDIConfig } from '../entities/cfdi-config.entity';
import { CANCELLATION_REASONS } from '../services/pac.service';
import {
CreateCFDIInput,
CancelCFDIInput,
ListCFDIsQuery,
ResendEmailInput,
CreateCreditNoteInput,
CreateCFDIConfigInput,
UpdateCFDIConfigInput,
AutofacturaRequest,
LookupOrderInput,
CFDIStatsQuery,
} from '../validation/cfdi.schema';
const cfdiService = new CFDIService(
AppDataSource,
AppDataSource.getRepository(CFDI)
);
const builderService = new CFDIBuilderService();
const configRepository = AppDataSource.getRepository(CFDIConfig);
// ==================== CFDI ENDPOINTS ====================
/**
* Create a new CFDI (invoice)
* POST /api/invoicing/cfdis
*/
export const createCFDI = async (
req: Request,
res: Response,
next: NextFunction
): Promise<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);
}
};

View File

@ -0,0 +1 @@
export * from './cfdi.controller';

View File

@ -0,0 +1,199 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum CFDIConfigStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
PENDING_VALIDATION = 'pending_validation',
EXPIRED = 'expired',
}
export enum PACProvider {
FINKOK = 'finkok',
SOLUCIONES_FISCALES = 'soluciones_fiscales',
FACTURAPI = 'facturapi',
SW_SAPIEN = 'sw_sapien',
DIGISAT = 'digisat',
OTHER = 'other',
}
@Entity('cfdi_configs', { schema: 'retail' })
@Index(['tenantId', 'branchId'])
@Index(['tenantId', 'rfc'], { unique: true })
@Index(['tenantId', 'status'])
export class CFDIConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
branchId: string;
@Column({
type: 'enum',
enum: CFDIConfigStatus,
default: CFDIConfigStatus.PENDING_VALIDATION,
})
status: CFDIConfigStatus;
// Emisor (Issuer) Info
@Column({ length: 13 })
rfc: string;
@Column({ name: 'razon_social', length: 300 })
razonSocial: string;
@Column({ name: 'nombre_comercial', length: 200, nullable: true })
nombreComercial: string;
@Column({ name: 'regimen_fiscal', length: 3 })
regimenFiscal: string; // SAT catalog code
@Column({ name: 'regimen_fiscal_descripcion', length: 200, nullable: true })
regimenFiscalDescripcion: string;
// Address
@Column({ name: 'codigo_postal', length: 5 })
codigoPostal: string;
@Column({ type: 'text', nullable: true })
domicilio: string;
// PAC Configuration
@Column({
name: 'pac_provider',
type: 'enum',
enum: PACProvider,
default: PACProvider.FINKOK,
})
pacProvider: PACProvider;
@Column({ name: 'pac_username', length: 100, nullable: true })
pacUsername: string;
@Column({ name: 'pac_password_encrypted', type: 'text', nullable: true })
pacPasswordEncrypted: string;
@Column({ name: 'pac_environment', length: 20, default: 'sandbox' })
pacEnvironment: 'sandbox' | 'production';
@Column({ name: 'pac_api_url', length: 255, nullable: true })
pacApiUrl: string;
// Certificate (CSD)
@Column({ name: 'certificate_number', length: 20, nullable: true })
certificateNumber: string;
@Column({ name: 'certificate_encrypted', type: 'text', nullable: true })
certificateEncrypted: string;
@Column({ name: 'private_key_encrypted', type: 'text', nullable: true })
privateKeyEncrypted: string;
@Column({ name: 'certificate_password_encrypted', type: 'text', nullable: true })
certificatePasswordEncrypted: string;
@Column({ name: 'certificate_valid_from', type: 'timestamp with time zone', nullable: true })
certificateValidFrom: Date;
@Column({ name: 'certificate_valid_until', type: 'timestamp with time zone', nullable: true })
certificateValidUntil: Date;
// CFDI defaults
@Column({ name: 'default_serie', length: 10, default: 'A' })
defaultSerie: string;
@Column({ name: 'current_folio', type: 'int', default: 1 })
currentFolio: number;
@Column({ name: 'default_forma_pago', length: 2, default: '01' })
defaultFormaPago: string; // 01=Efectivo, 03=Transferencia, etc.
@Column({ name: 'default_metodo_pago', length: 3, default: 'PUE' })
defaultMetodoPago: string; // PUE=Pago en Una Exhibición, PPD=Pago en Parcialidades
@Column({ name: 'default_uso_cfdi', length: 4, default: 'G03' })
defaultUsoCFDI: string; // G03=Gastos en general
@Column({ name: 'default_moneda', length: 3, default: 'MXN' })
defaultMoneda: string;
// Logo and branding
@Column({ name: 'logo_url', length: 255, nullable: true })
logoUrl: string;
@Column({ name: 'pdf_template', length: 50, default: 'standard' })
pdfTemplate: string;
@Column({ name: 'footer_text', type: 'text', nullable: true })
footerText: string;
// Email settings
@Column({ name: 'send_email_on_issue', type: 'boolean', default: true })
sendEmailOnIssue: boolean;
@Column({ name: 'email_from', length: 100, nullable: true })
emailFrom: string;
@Column({ name: 'email_subject_template', length: 200, nullable: true })
emailSubjectTemplate: string;
@Column({ name: 'email_body_template', type: 'text', nullable: true })
emailBodyTemplate: string;
// Notification settings
@Column({ name: 'notify_certificate_expiry', type: 'boolean', default: true })
notifyCertificateExpiry: boolean;
@Column({ name: 'notify_days_before_expiry', type: 'int', default: 30 })
notifyDaysBeforeExpiry: number;
// Auto-invoicing
@Column({ name: 'auto_invoice_pos', type: 'boolean', default: false })
autoInvoicePOS: boolean;
@Column({ name: 'auto_invoice_ecommerce', type: 'boolean', default: true })
autoInvoiceEcommerce: boolean;
// Rate limiting
@Column({ name: 'max_invoices_per_day', type: 'int', nullable: true })
maxInvoicesPerDay: number;
@Column({ name: 'invoices_issued_today', type: 'int', default: 0 })
invoicesIssuedToday: number;
@Column({ name: 'last_invoice_reset', type: 'date', nullable: true })
lastInvoiceReset: Date;
// Validation
@Column({ name: 'last_validated_at', type: 'timestamp with time zone', nullable: true })
lastValidatedAt: Date;
@Column({ name: 'validation_errors', type: 'jsonb', nullable: true })
validationErrors: string[];
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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;
}

View File

@ -0,0 +1,294 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum CFDIStatus {
DRAFT = 'draft',
PENDING = 'pending',
STAMPED = 'stamped',
CANCELLED = 'cancelled',
CANCELLATION_PENDING = 'cancellation_pending',
ERROR = 'error',
}
export enum CFDIType {
INGRESO = 'I', // Income (sale invoice)
EGRESO = 'E', // Expense (credit note)
TRASLADO = 'T', // Transfer
NOMINA = 'N', // Payroll
PAGO = 'P', // Payment receipt
}
@Entity('cfdis', { schema: 'retail' })
@Index(['tenantId', 'status', 'fechaEmision'])
@Index(['tenantId', 'uuid'], { unique: true })
@Index(['tenantId', 'serie', 'folio'], { unique: true })
@Index(['tenantId', 'orderId'])
@Index(['tenantId', 'receptorRfc'])
export class CFDI {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'config_id', type: 'uuid' })
configId: string;
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
branchId: string;
@Column({
type: 'enum',
enum: CFDIStatus,
default: CFDIStatus.DRAFT,
})
status: CFDIStatus;
@Column({
name: 'tipo_comprobante',
type: 'enum',
enum: CFDIType,
default: CFDIType.INGRESO,
})
tipoComprobante: CFDIType;
// CFDI identification
@Column({ type: 'uuid', nullable: true })
uuid: string; // UUID assigned by SAT after stamping
@Column({ length: 10 })
serie: string;
@Column({ type: 'int' })
folio: number;
@Column({ name: 'fecha_emision', type: 'timestamp with time zone' })
fechaEmision: Date;
@Column({ name: 'fecha_timbrado', type: 'timestamp with time zone', nullable: true })
fechaTimbrado: Date;
// Emisor (Issuer)
@Column({ name: 'emisor_rfc', length: 13 })
emisorRfc: string;
@Column({ name: 'emisor_nombre', length: 300 })
emisorNombre: string;
@Column({ name: 'emisor_regimen_fiscal', length: 3 })
emisorRegimenFiscal: string;
// Receptor (Receiver)
@Column({ name: 'receptor_rfc', length: 13 })
receptorRfc: string;
@Column({ name: 'receptor_nombre', length: 300 })
receptorNombre: string;
@Column({ name: 'receptor_uso_cfdi', length: 4 })
receptorUsoCFDI: string;
@Column({ name: 'receptor_regimen_fiscal', length: 3, nullable: true })
receptorRegimenFiscal: string;
@Column({ name: 'receptor_domicilio_fiscal', length: 5, nullable: true })
receptorDomicilioFiscal: string;
@Column({ name: 'receptor_email', length: 100, nullable: true })
receptorEmail: string;
// Amounts
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
subtotal: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
descuento: number;
@Column({ name: 'total_impuestos_trasladados', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalImpuestosTrasladados: number;
@Column({ name: 'total_impuestos_retenidos', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalImpuestosRetenidos: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
total: number;
// Payment info
@Column({ name: 'forma_pago', length: 2 })
formaPago: string;
@Column({ name: 'metodo_pago', length: 3 })
metodoPago: string;
@Column({ length: 3, default: 'MXN' })
moneda: string;
@Column({ name: 'tipo_cambio', type: 'decimal', precision: 10, scale: 6, default: 1 })
tipoCambio: number;
@Column({ name: 'condiciones_pago', length: 100, nullable: true })
condicionesPago: string;
// Concepts (line items)
@Column({ type: 'jsonb' })
conceptos: {
claveProdServ: string;
claveUnidad: string;
cantidad: number;
unidad: string;
descripcion: string;
valorUnitario: number;
importe: number;
descuento?: number;
objetoImp: string;
impuestos?: {
traslados?: {
base: number;
impuesto: string;
tipoFactor: string;
tasaOCuota: number;
importe: number;
}[];
retenciones?: {
base: number;
impuesto: string;
tipoFactor: string;
tasaOCuota: number;
importe: number;
}[];
};
// Retail-specific
productId?: string;
orderLineId?: string;
}[];
// Tax summary
@Column({ name: 'impuestos', type: 'jsonb', nullable: true })
impuestos: {
traslados?: {
impuesto: string;
tipoFactor: string;
tasaOCuota: number;
importe: number;
base: number;
}[];
retenciones?: {
impuesto: string;
importe: number;
}[];
};
// Related CFDIs
@Column({ name: 'cfdi_relacionados', type: 'jsonb', nullable: true })
cfdiRelacionados: {
tipoRelacion: string;
uuids: string[];
};
// Seal and stamps
@Column({ name: 'sello_cfdi', type: 'text', nullable: true })
selloCFDI: string;
@Column({ name: 'sello_sat', type: 'text', nullable: true })
selloSAT: string;
@Column({ name: 'certificado_sat', length: 20, nullable: true })
certificadoSAT: string;
@Column({ name: 'cadena_original_sat', type: 'text', nullable: true })
cadenaOriginalSAT: string;
// XML content
@Column({ name: 'xml_content', type: 'text', nullable: true })
xmlContent: string;
// PDF
@Column({ name: 'pdf_url', length: 255, nullable: true })
pdfUrl: string;
@Column({ name: 'pdf_generated_at', type: 'timestamp with time zone', nullable: true })
pdfGeneratedAt: Date;
// QR code data
@Column({ name: 'qr_data', type: 'text', nullable: true })
qrData: string;
// Reference to original order
@Column({ name: 'order_id', type: 'uuid', nullable: true })
orderId: string;
@Column({ name: 'order_number', length: 30, nullable: true })
orderNumber: string;
@Column({ name: 'order_type', length: 20, nullable: true })
orderType: 'pos' | 'ecommerce';
// Customer reference
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId: string;
// Cancellation
@Column({ name: 'cancelled_at', type: 'timestamp with time zone', nullable: true })
cancelledAt: Date;
@Column({ name: 'cancelled_by', type: 'uuid', nullable: true })
cancelledBy: string;
@Column({ name: 'cancellation_reason', length: 2, nullable: true })
cancellationReason: string; // SAT cancellation code
@Column({ name: 'cancellation_uuid', type: 'uuid', nullable: true })
cancellationUuid: string;
@Column({ name: 'cancellation_status', length: 50, nullable: true })
cancellationStatus: string;
@Column({ name: 'substitute_uuid', type: 'uuid', nullable: true })
substituteUuid: string;
// Email
@Column({ name: 'email_sent', type: 'boolean', default: false })
emailSent: boolean;
@Column({ name: 'email_sent_at', type: 'timestamp with time zone', nullable: true })
emailSentAt: Date;
@Column({ name: 'email_sent_to', length: 100, nullable: true })
emailSentTo: string;
// PAC response
@Column({ name: 'pac_response', type: 'jsonb', nullable: true })
pacResponse: Record<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;
}

View File

@ -0,0 +1,2 @@
export * from './cfdi-config.entity';
export * from './cfdi.entity';

View File

@ -0,0 +1,14 @@
// Services
export * from './services';
// Controllers
export * from './controllers';
// Routes
export * from './routes';
// Validation
export * from './validation';
// Entities
export * from './entities';

View File

@ -0,0 +1,222 @@
import { Router } from 'express';
import { authMiddleware } from '../../auth/middleware/auth.middleware';
import { branchMiddleware } from '../../branches/middleware/branch.middleware';
import { validateRequest } from '../../../shared/middleware/validation.middleware';
import { requirePermissions } from '../../../shared/middleware/permissions.middleware';
import {
createCFDISchema,
cancelCFDISchema,
listCFDIsQuerySchema,
resendEmailSchema,
createCreditNoteSchema,
createCFDIConfigSchema,
updateCFDIConfigSchema,
validateCFDIConfigSchema,
autofacturaRequestSchema,
lookupOrderSchema,
cfdiStatsQuerySchema,
} from '../validation/cfdi.schema';
import * as cfdiController from '../controllers/cfdi.controller';
const router = Router();
// ==================== PUBLIC ROUTES (Autofactura) ====================
/**
* Lookup order for autofactura (public)
*/
router.post(
'/autofactura/lookup',
validateRequest(lookupOrderSchema),
cfdiController.lookupOrder
);
/**
* Create autofactura (public)
*/
router.post(
'/autofactura/create',
validateRequest(autofacturaRequestSchema),
cfdiController.createAutofactura
);
/**
* Get UsoCFDI options (public for forms)
*/
router.get('/uso-cfdi-options', cfdiController.getUsoCFDIOptions);
/**
* Get common SAT product codes (public for forms)
*/
router.get('/claves-prod-serv', cfdiController.getClavesProdServ);
/**
* Get cancellation reasons (public for forms)
*/
router.get('/cancellation-reasons', cfdiController.getCancellationReasons);
// ==================== AUTHENTICATED ROUTES ====================
// All routes below require authentication
router.use(authMiddleware);
// ==================== CFDI ROUTES ====================
/**
* List CFDIs with filters
*/
router.get(
'/cfdis',
validateRequest(listCFDIsQuerySchema, 'query'),
requirePermissions('cfdi:view'),
cfdiController.listCFDIs
);
/**
* Get CFDI statistics
*/
router.get(
'/stats',
validateRequest(cfdiStatsQuerySchema, 'query'),
requirePermissions('cfdi:view'),
cfdiController.getCFDIStats
);
/**
* Get CFDI by ID
*/
router.get(
'/cfdis/:id',
requirePermissions('cfdi:view'),
cfdiController.getCFDI
);
/**
* Get CFDI by UUID
*/
router.get(
'/cfdis/uuid/:uuid',
requirePermissions('cfdi:view'),
cfdiController.getCFDIByUUID
);
/**
* Create a new CFDI
*/
router.post(
'/cfdis',
branchMiddleware,
validateRequest(createCFDISchema),
requirePermissions('cfdi:create'),
cfdiController.createCFDI
);
/**
* Stamp a draft CFDI
*/
router.post(
'/cfdis/:id/stamp',
requirePermissions('cfdi:stamp'),
cfdiController.stampCFDI
);
/**
* Cancel a CFDI
*/
router.post(
'/cfdis/:id/cancel',
validateRequest(cancelCFDISchema),
requirePermissions('cfdi:cancel'),
cfdiController.cancelCFDI
);
/**
* Verify CFDI status with SAT
*/
router.get(
'/cfdis/:id/verify',
requirePermissions('cfdi:view'),
cfdiController.verifyCFDI
);
/**
* Download CFDI XML
*/
router.get(
'/cfdis/:id/xml',
requirePermissions('cfdi:view'),
cfdiController.downloadXML
);
/**
* Resend CFDI via email
*/
router.post(
'/cfdis/:id/resend-email',
validateRequest(resendEmailSchema),
requirePermissions('cfdi:send'),
cfdiController.resendEmail
);
/**
* Create credit note for a CFDI
*/
router.post(
'/cfdis/:id/credit-note',
branchMiddleware,
validateRequest(createCreditNoteSchema.omit({ originalCfdiId: true })),
requirePermissions('cfdi:create'),
cfdiController.createCreditNote
);
// ==================== CONFIG ROUTES ====================
/**
* Get active CFDI configuration
*/
router.get(
'/config',
requirePermissions('cfdi:config:view'),
cfdiController.getConfig
);
/**
* Create or update CFDI configuration
*/
router.post(
'/config',
validateRequest(createCFDIConfigSchema),
requirePermissions('cfdi:config:manage'),
cfdiController.upsertConfig
);
/**
* Update CFDI configuration
*/
router.put(
'/config',
validateRequest(updateCFDIConfigSchema),
requirePermissions('cfdi:config:manage'),
cfdiController.upsertConfig
);
/**
* Upload certificate (CSD)
*/
router.post(
'/config/certificate',
requirePermissions('cfdi:config:manage'),
cfdiController.uploadCertificate
);
/**
* Validate CFDI configuration
*/
router.post(
'/config/validate',
validateRequest(validateCFDIConfigSchema),
requirePermissions('cfdi:config:view'),
cfdiController.validateConfig
);
export default router;

View File

@ -0,0 +1 @@
export { default as cfdiRoutes } from './cfdi.routes';

View File

@ -0,0 +1,507 @@
import { CFDIConfig } from '../entities/cfdi-config.entity';
import { CFDIType } from '../entities/cfdi.entity';
import { CFDIData, CFDIConcepto, CFDIImpuestos, XMLService } from './xml.service';
export interface OrderLineData {
productId?: string;
productCode: string;
productName: string;
claveProdServ: string; // SAT product/service code
claveUnidad: string; // SAT unit code
unidad: string; // Unit description
quantity: number;
unitPrice: number;
discount?: number;
taxRate: number; // e.g., 0.16 for 16% IVA
taxIncluded?: boolean; // Whether price includes tax
retentionIVA?: number; // IVA retention rate (e.g., 0.106667)
retentionISR?: number; // ISR retention rate
objetoImp?: string; // Tax object code (01, 02, 03)
}
export interface ReceptorData {
rfc: string;
nombre: string;
usoCFDI: string;
regimenFiscal?: string;
domicilioFiscal?: string;
email?: string;
}
export interface CFDIBuildInput {
config: CFDIConfig;
receptor: ReceptorData;
lines: OrderLineData[];
formaPago: string;
metodoPago: string;
moneda?: string;
tipoCambio?: number;
condicionesPago?: string;
tipoComprobante?: CFDIType;
cfdiRelacionados?: {
tipoRelacion: string;
uuids: string[];
};
orderId?: string;
orderNumber?: string;
orderType?: 'pos' | 'ecommerce';
notes?: string;
}
export interface CFDIBuildResult {
cfdiData: CFDIData;
subtotal: number;
descuento: number;
totalImpuestosTrasladados: number;
totalImpuestosRetenidos: number;
total: number;
conceptos: CFDIConcepto[];
}
// SAT Tax Types
const TAX_IVA = '002';
const TAX_ISR = '001';
const TAX_IEPS = '003';
// SAT Tax Factor Types
const FACTOR_TASA = 'Tasa';
const FACTOR_CUOTA = 'Cuota';
const FACTOR_EXENTO = 'Exento';
// SAT Object of Tax Codes
const OBJETO_IMP = {
NO_OBJETO: '01', // Not subject to tax
SI_OBJETO: '02', // Subject to tax
SI_NO_DESGLOSE: '03', // Subject to tax but no breakdown
};
export class CFDIBuilderService {
private xmlService: XMLService;
constructor() {
this.xmlService = new XMLService();
}
/**
* Build complete CFDI data structure from order data
*/
buildFromOrder(input: CFDIBuildInput): CFDIBuildResult {
const { config, receptor, lines, tipoComprobante = CFDIType.INGRESO } = input;
// Process each line to calculate taxes
const processedLines = lines.map(line => this.processLine(line));
// Calculate totals
const subtotal = processedLines.reduce((sum, l) => sum + l.importe, 0);
const descuento = processedLines.reduce((sum, l) => sum + (l.descuento || 0), 0);
// Aggregate taxes
const aggregatedTaxes = this.aggregateTaxes(processedLines);
const totalImpuestosTrasladados = aggregatedTaxes.traslados.reduce((sum, t) => sum + t.importe, 0);
const totalImpuestosRetenidos = aggregatedTaxes.retenciones.reduce((sum, r) => sum + r.importe, 0);
const total = subtotal - descuento + totalImpuestosTrasladados - totalImpuestosRetenidos;
// Build CFDI data structure
const cfdiData: CFDIData = {
version: '4.0',
serie: config.defaultSerie,
folio: config.currentFolio,
fecha: this.xmlService.formatCFDIDate(new Date()),
formaPago: input.formaPago || config.defaultFormaPago,
metodoPago: input.metodoPago || config.defaultMetodoPago,
tipoDeComprobante: tipoComprobante,
exportacion: '01', // No exportación
lugarExpedicion: config.codigoPostal,
moneda: input.moneda || config.defaultMoneda,
tipoCambio: input.tipoCambio,
subTotal: this.round(subtotal),
descuento: descuento > 0 ? this.round(descuento) : undefined,
total: this.round(total),
condicionesDePago: input.condicionesPago,
emisor: {
rfc: config.rfc,
nombre: config.razonSocial,
regimenFiscal: config.regimenFiscal,
},
receptor: {
rfc: receptor.rfc,
nombre: receptor.nombre,
usoCFDI: receptor.usoCFDI || config.defaultUsoCFDI,
domicilioFiscalReceptor: receptor.domicilioFiscal,
regimenFiscalReceptor: receptor.regimenFiscal,
},
conceptos: processedLines,
impuestos: aggregatedTaxes.traslados.length > 0 || aggregatedTaxes.retenciones.length > 0
? {
traslados: aggregatedTaxes.traslados.length > 0 ? aggregatedTaxes.traslados : undefined,
retenciones: aggregatedTaxes.retenciones.length > 0 ? aggregatedTaxes.retenciones : undefined,
}
: undefined,
cfdiRelacionados: input.cfdiRelacionados,
};
return {
cfdiData,
subtotal: this.round(subtotal),
descuento: this.round(descuento),
totalImpuestosTrasladados: this.round(totalImpuestosTrasladados),
totalImpuestosRetenidos: this.round(totalImpuestosRetenidos),
total: this.round(total),
conceptos: processedLines,
};
}
/**
* Build credit note (Nota de Crédito) CFDI
*/
buildCreditNote(
config: CFDIConfig,
receptor: ReceptorData,
originalUuid: string,
lines: OrderLineData[],
reason: string
): CFDIBuildResult {
const result = this.buildFromOrder({
config,
receptor,
lines,
formaPago: '99', // Por definir
metodoPago: 'PUE',
tipoComprobante: CFDIType.EGRESO,
cfdiRelacionados: {
tipoRelacion: '01', // Nota de crédito de los documentos relacionados
uuids: [originalUuid],
},
});
return result;
}
/**
* Build payment complement CFDI (Complemento de Pago)
*/
buildPaymentComplement(
config: CFDIConfig,
receptor: ReceptorData,
relatedCfdis: Array<{
uuid: string;
serie: string;
folio: string;
total: number;
saldoAnterior: number;
importePagado: number;
saldoInsoluto: number;
}>,
paymentData: {
fecha: Date;
formaPago: string;
moneda: string;
tipoCambio?: number;
monto: number;
numOperacion?: string;
rfcBancoEmisor?: string;
ctaOrdenante?: string;
rfcBancoBeneficiario?: string;
ctaBeneficiario?: string;
}
): CFDIData {
// Payment CFDIs have special structure
const cfdiData: CFDIData = {
version: '4.0',
serie: config.defaultSerie,
folio: config.currentFolio,
fecha: this.xmlService.formatCFDIDate(new Date()),
formaPago: '', // Not used in payment CFDI
metodoPago: '', // Not used
tipoDeComprobante: CFDIType.PAGO,
exportacion: '01',
lugarExpedicion: config.codigoPostal,
moneda: 'XXX', // Required for payment CFDIs
subTotal: 0,
total: 0,
emisor: {
rfc: config.rfc,
nombre: config.razonSocial,
regimenFiscal: config.regimenFiscal,
},
receptor: {
rfc: receptor.rfc,
nombre: receptor.nombre,
usoCFDI: 'CP01', // Por definir - mandatory for payments
domicilioFiscalReceptor: receptor.domicilioFiscal,
regimenFiscalReceptor: receptor.regimenFiscal,
},
conceptos: [{
claveProdServ: '84111506', // Servicios de facturación
claveUnidad: 'ACT', // Actividad
cantidad: 1,
unidad: 'Actividad',
descripcion: 'Pago',
valorUnitario: 0,
importe: 0,
objetoImp: '01',
}],
};
// Note: The actual payment complement (Pagos 2.0) would be added as a Complemento
// This requires additional XML structure beyond basic CFDI
return cfdiData;
}
/**
* Process a single line item to CFDI concepto
*/
private processLine(line: OrderLineData): CFDIConcepto {
let valorUnitario: number;
let importe: number;
let taxBase: number;
if (line.taxIncluded) {
// If price includes tax, we need to extract the base price
const taxDivisor = 1 + line.taxRate;
valorUnitario = line.unitPrice / taxDivisor;
importe = valorUnitario * line.quantity;
} else {
valorUnitario = line.unitPrice;
importe = valorUnitario * line.quantity;
}
// Apply discount if any
const descuento = line.discount || 0;
taxBase = importe - descuento;
// Build tax structure
const traslados: CFDIConcepto['impuestos']['traslados'] = [];
const retenciones: CFDIConcepto['impuestos']['retenciones'] = [];
// Determine object of tax
const objetoImp = line.objetoImp || (line.taxRate > 0 ? OBJETO_IMP.SI_OBJETO : OBJETO_IMP.NO_OBJETO);
// Only add taxes if subject to tax
if (objetoImp === OBJETO_IMP.SI_OBJETO) {
// IVA traslado
if (line.taxRate > 0) {
traslados.push({
base: this.round(taxBase),
impuesto: TAX_IVA,
tipoFactor: FACTOR_TASA,
tasaOCuota: line.taxRate,
importe: this.round(taxBase * line.taxRate),
});
} else if (line.taxRate === 0) {
// Tasa 0%
traslados.push({
base: this.round(taxBase),
impuesto: TAX_IVA,
tipoFactor: FACTOR_TASA,
tasaOCuota: 0,
importe: 0,
});
}
// IVA retention
if (line.retentionIVA && line.retentionIVA > 0) {
retenciones.push({
base: this.round(taxBase),
impuesto: TAX_IVA,
tipoFactor: FACTOR_TASA,
tasaOCuota: line.retentionIVA,
importe: this.round(taxBase * line.retentionIVA),
});
}
// ISR retention
if (line.retentionISR && line.retentionISR > 0) {
retenciones.push({
base: this.round(taxBase),
impuesto: TAX_ISR,
tipoFactor: FACTOR_TASA,
tasaOCuota: line.retentionISR,
importe: this.round(taxBase * line.retentionISR),
});
}
}
return {
claveProdServ: line.claveProdServ,
claveUnidad: line.claveUnidad,
cantidad: line.quantity,
unidad: line.unidad,
descripcion: line.productName,
valorUnitario: this.round(valorUnitario),
importe: this.round(importe),
descuento: descuento > 0 ? this.round(descuento) : undefined,
objetoImp,
impuestos: (traslados.length > 0 || retenciones.length > 0) ? {
traslados: traslados.length > 0 ? traslados : undefined,
retenciones: retenciones.length > 0 ? retenciones : undefined,
} : undefined,
};
}
/**
* Aggregate taxes from all conceptos
*/
private aggregateTaxes(conceptos: CFDIConcepto[]): CFDIImpuestos {
const trasladosMap = new Map<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;
}
}

View File

@ -0,0 +1,815 @@
import { DataSource, Repository, QueryRunner } from 'typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { CFDI, CFDIStatus, CFDIType } from '../entities/cfdi.entity';
import { CFDIConfig, CFDIConfigStatus } from '../entities/cfdi-config.entity';
import { XMLService } from './xml.service';
import { PACService, CANCELLATION_REASONS } from './pac.service';
import { CFDIBuilderService, OrderLineData, ReceptorData } from './cfdi-builder.service';
export interface ServiceResult<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,
};
}
}

View File

@ -0,0 +1,4 @@
export * from './cfdi.service';
export * from './cfdi-builder.service';
export * from './xml.service';
export * from './pac.service';

View File

@ -0,0 +1,755 @@
import crypto from 'crypto';
import { CFDIConfig, PACProvider } from '../entities/cfdi-config.entity';
export interface StampResult {
success: boolean;
uuid?: string;
fechaTimbrado?: string;
xml?: string;
acuseRecepcion?: string;
errorCode?: string;
errorMessage?: string;
}
export interface CancelResult {
success: boolean;
acuse?: string;
status?: string;
errorCode?: string;
errorMessage?: string;
}
export interface VerifyStatusResult {
success: boolean;
status: 'valid' | 'cancelled' | 'not_found' | 'error';
cancellationStatus?: string;
cancellationDate?: string;
errorCode?: string;
errorMessage?: string;
}
interface PACEndpoints {
stamp: string;
cancel: string;
status: string;
}
const PAC_ENDPOINTS: Record<PACProvider, { 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');
}
}

View File

@ -0,0 +1,534 @@
import { create, fragment } from 'xmlbuilder2';
import { CFDI, CFDIType } from '../entities/cfdi.entity';
import { CFDIConfig } from '../entities/cfdi-config.entity';
export interface CFDIConcepto {
claveProdServ: string;
claveUnidad: string;
cantidad: number;
unidad: string;
descripcion: string;
valorUnitario: number;
importe: number;
descuento?: number;
objetoImp: string;
impuestos?: {
traslados?: {
base: number;
impuesto: string;
tipoFactor: string;
tasaOCuota: number;
importe: number;
}[];
retenciones?: {
base: number;
impuesto: string;
tipoFactor: string;
tasaOCuota: number;
importe: number;
}[];
};
}
export interface CFDIImpuestos {
traslados?: {
impuesto: string;
tipoFactor: string;
tasaOCuota: number;
importe: number;
base: number;
}[];
retenciones?: {
impuesto: string;
importe: number;
}[];
}
export interface CFDIData {
version: string;
serie: string;
folio: number;
fecha: string;
formaPago: string;
metodoPago: string;
tipoDeComprobante: string;
exportacion: string;
lugarExpedicion: string;
moneda: string;
tipoCambio?: number;
subTotal: number;
descuento?: number;
total: number;
condicionesDePago?: string;
emisor: {
rfc: string;
nombre: string;
regimenFiscal: string;
};
receptor: {
rfc: string;
nombre: string;
usoCFDI: string;
domicilioFiscalReceptor?: string;
regimenFiscalReceptor?: string;
};
conceptos: CFDIConcepto[];
impuestos?: CFDIImpuestos;
cfdiRelacionados?: {
tipoRelacion: string;
uuids: string[];
};
}
export interface TimbreData {
version: string;
uuid: string;
fechaTimbrado: string;
rfcProvCertif: string;
selloCFD: string;
noCertificadoSAT: string;
selloSAT: string;
}
const CFDI_NAMESPACE = 'http://www.sat.gob.mx/cfd/4';
const TIMBRE_NAMESPACE = 'http://www.sat.gob.mx/TimbreFiscalDigital';
const XSI_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance';
export class XMLService {
/**
* Build CFDI XML without stamps (for signing)
*/
buildCFDIXML(data: CFDIData, certificateNumber: string): string {
const schemaLocation = `${CFDI_NAMESPACE} http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd`;
const doc = create({ version: '1.0', encoding: 'UTF-8' })
.ele(CFDI_NAMESPACE, 'cfdi:Comprobante')
.att('xmlns:cfdi', CFDI_NAMESPACE)
.att('xmlns:xsi', XSI_NAMESPACE)
.att('xsi:schemaLocation', schemaLocation)
.att('Version', data.version)
.att('Serie', data.serie)
.att('Folio', data.folio.toString())
.att('Fecha', data.fecha)
.att('FormaPago', data.formaPago)
.att('SubTotal', this.formatMoney(data.subTotal))
.att('Moneda', data.moneda)
.att('Total', this.formatMoney(data.total))
.att('TipoDeComprobante', data.tipoDeComprobante)
.att('Exportacion', data.exportacion)
.att('MetodoPago', data.metodoPago)
.att('LugarExpedicion', data.lugarExpedicion)
.att('NoCertificado', certificateNumber);
if (data.tipoCambio && data.tipoCambio !== 1) {
doc.att('TipoCambio', data.tipoCambio.toString());
}
if (data.descuento && data.descuento > 0) {
doc.att('Descuento', this.formatMoney(data.descuento));
}
if (data.condicionesDePago) {
doc.att('CondicionesDePago', data.condicionesDePago);
}
// CFDI Relacionados
if (data.cfdiRelacionados && data.cfdiRelacionados.uuids.length > 0) {
const relacionados = doc.ele('cfdi:CfdiRelacionados')
.att('TipoRelacion', data.cfdiRelacionados.tipoRelacion);
for (const uuid of data.cfdiRelacionados.uuids) {
relacionados.ele('cfdi:CfdiRelacionado').att('UUID', uuid);
}
}
// Emisor
doc.ele('cfdi:Emisor')
.att('Rfc', data.emisor.rfc)
.att('Nombre', data.emisor.nombre)
.att('RegimenFiscal', data.emisor.regimenFiscal);
// Receptor
const receptor = doc.ele('cfdi:Receptor')
.att('Rfc', data.receptor.rfc)
.att('Nombre', data.receptor.nombre)
.att('UsoCFDI', data.receptor.usoCFDI);
if (data.receptor.domicilioFiscalReceptor) {
receptor.att('DomicilioFiscalReceptor', data.receptor.domicilioFiscalReceptor);
}
if (data.receptor.regimenFiscalReceptor) {
receptor.att('RegimenFiscalReceptor', data.receptor.regimenFiscalReceptor);
}
// Conceptos
const conceptos = doc.ele('cfdi:Conceptos');
for (const concepto of data.conceptos) {
const conceptoNode = conceptos.ele('cfdi:Concepto')
.att('ClaveProdServ', concepto.claveProdServ)
.att('Cantidad', concepto.cantidad.toString())
.att('ClaveUnidad', concepto.claveUnidad)
.att('Unidad', concepto.unidad)
.att('Descripcion', concepto.descripcion)
.att('ValorUnitario', this.formatMoney(concepto.valorUnitario))
.att('Importe', this.formatMoney(concepto.importe))
.att('ObjetoImp', concepto.objetoImp);
if (concepto.descuento && concepto.descuento > 0) {
conceptoNode.att('Descuento', this.formatMoney(concepto.descuento));
}
// Impuestos del concepto
if (concepto.impuestos) {
const impuestosNode = conceptoNode.ele('cfdi:Impuestos');
if (concepto.impuestos.traslados && concepto.impuestos.traslados.length > 0) {
const trasladosNode = impuestosNode.ele('cfdi:Traslados');
for (const traslado of concepto.impuestos.traslados) {
trasladosNode.ele('cfdi:Traslado')
.att('Base', this.formatMoney(traslado.base))
.att('Impuesto', traslado.impuesto)
.att('TipoFactor', traslado.tipoFactor)
.att('TasaOCuota', this.formatRate(traslado.tasaOCuota))
.att('Importe', this.formatMoney(traslado.importe));
}
}
if (concepto.impuestos.retenciones && concepto.impuestos.retenciones.length > 0) {
const retencionesNode = impuestosNode.ele('cfdi:Retenciones');
for (const retencion of concepto.impuestos.retenciones) {
retencionesNode.ele('cfdi:Retencion')
.att('Base', this.formatMoney(retencion.base))
.att('Impuesto', retencion.impuesto)
.att('TipoFactor', retencion.tipoFactor)
.att('TasaOCuota', this.formatRate(retencion.tasaOCuota))
.att('Importe', this.formatMoney(retencion.importe));
}
}
}
}
// Impuestos generales
if (data.impuestos) {
const impuestosNode = doc.ele('cfdi:Impuestos');
if (data.impuestos.retenciones && data.impuestos.retenciones.length > 0) {
const totalRetenido = data.impuestos.retenciones.reduce((sum, r) => sum + r.importe, 0);
impuestosNode.att('TotalImpuestosRetenidos', this.formatMoney(totalRetenido));
const retencionesNode = impuestosNode.ele('cfdi:Retenciones');
for (const retencion of data.impuestos.retenciones) {
retencionesNode.ele('cfdi:Retencion')
.att('Impuesto', retencion.impuesto)
.att('Importe', this.formatMoney(retencion.importe));
}
}
if (data.impuestos.traslados && data.impuestos.traslados.length > 0) {
const totalTrasladado = data.impuestos.traslados.reduce((sum, t) => sum + t.importe, 0);
impuestosNode.att('TotalImpuestosTrasladados', this.formatMoney(totalTrasladado));
const trasladosNode = impuestosNode.ele('cfdi:Traslados');
for (const traslado of data.impuestos.traslados) {
trasladosNode.ele('cfdi:Traslado')
.att('Base', this.formatMoney(traslado.base))
.att('Impuesto', traslado.impuesto)
.att('TipoFactor', traslado.tipoFactor)
.att('TasaOCuota', this.formatRate(traslado.tasaOCuota))
.att('Importe', this.formatMoney(traslado.importe));
}
}
}
return doc.end({ prettyPrint: false });
}
/**
* Add seal (sello) to XML
*/
addSealToXML(xml: string, sello: string): string {
// Insert Sello attribute after NoCertificado
return xml.replace(
/NoCertificado="([^"]+)"/,
`NoCertificado="$1" Sello="${sello}"`
);
}
/**
* Add certificate to XML
*/
addCertificateToXML(xml: string, certificateBase64: string): string {
// Insert Certificado attribute after Sello
return xml.replace(
/Sello="([^"]+)"/,
`Sello="$1" Certificado="${certificateBase64}"`
);
}
/**
* Add TimbreFiscalDigital complement to stamped XML
*/
addTimbreToXML(xml: string, timbre: TimbreData): string {
const timbreFragment = fragment()
.ele(TIMBRE_NAMESPACE, 'tfd:TimbreFiscalDigital')
.att('xmlns:tfd', TIMBRE_NAMESPACE)
.att('xmlns:xsi', XSI_NAMESPACE)
.att('xsi:schemaLocation', `${TIMBRE_NAMESPACE} http://www.sat.gob.mx/sitio_internet/cfd/TimbreFiscalDigital/TimbreFiscalDigitalv11.xsd`)
.att('Version', timbre.version)
.att('UUID', timbre.uuid)
.att('FechaTimbrado', timbre.fechaTimbrado)
.att('RfcProvCertif', timbre.rfcProvCertif)
.att('SelloCFD', timbre.selloCFD)
.att('NoCertificadoSAT', timbre.noCertificadoSAT)
.att('SelloSAT', timbre.selloSAT)
.end();
// Find closing tag and insert Complemento before it
const insertPoint = xml.indexOf('</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,
};
}
}

View File

@ -0,0 +1,245 @@
import { z } from 'zod';
import {
uuidSchema,
moneySchema,
paginationSchema,
} from '../../../shared/validation/common.schema';
// RFC validation (12-13 characters)
const rfcSchema = z.string()
.min(12, 'RFC must be at least 12 characters')
.max(13, 'RFC must be at most 13 characters')
.regex(/^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/i, 'Invalid RFC format');
// SAT catalog codes
const claveProdServSchema = z.string().length(8, 'ClaveProdServ must be 8 digits').regex(/^\d{8}$/);
const claveUnidadSchema = z.string().min(1).max(4);
const regimenFiscalSchema = z.string().length(3, 'RegimenFiscal must be 3 characters');
const usoCFDISchema = z.string().length(3).or(z.string().length(4));
const codigoPostalSchema = z.string().length(5, 'Código postal must be 5 digits').regex(/^\d{5}$/);
const formaPagoSchema = z.string().length(2, 'FormaPago must be 2 characters');
const metodoPagoSchema = z.enum(['PUE', 'PPD']);
const monedaSchema = z.string().length(3, 'Moneda must be 3 characters').default('MXN');
// CFDI Status enum
export const cfdiStatusEnum = z.enum([
'draft',
'pending',
'stamped',
'cancelled',
'cancellation_pending',
'error',
]);
// CFDI Type enum
export const cfdiTypeEnum = z.enum(['I', 'E', 'T', 'N', 'P']);
// Cancellation reason enum
export const cancellationReasonEnum = z.enum(['01', '02', '03', '04']);
// ==================== RECEPTOR SCHEMAS ====================
export const receptorSchema = z.object({
rfc: rfcSchema,
nombre: z.string().min(1).max(300),
usoCFDI: usoCFDISchema,
regimenFiscal: regimenFiscalSchema.optional(),
domicilioFiscal: codigoPostalSchema.optional(),
email: z.string().email().max(100).optional(),
});
// ==================== LINE ITEM SCHEMAS ====================
export const orderLineSchema = z.object({
productId: uuidSchema.optional(),
productCode: z.string().min(1).max(50),
productName: z.string().min(1).max(1000),
claveProdServ: claveProdServSchema,
claveUnidad: claveUnidadSchema,
unidad: z.string().min(1).max(20),
quantity: z.number().positive(),
unitPrice: moneySchema.positive(),
discount: moneySchema.optional(),
taxRate: z.number().min(0).max(1).default(0.16),
taxIncluded: z.boolean().optional(),
retentionIVA: z.number().min(0).max(1).optional(),
retentionISR: z.number().min(0).max(1).optional(),
objetoImp: z.enum(['01', '02', '03']).optional(),
});
// ==================== CREATE CFDI SCHEMA ====================
export const createCFDISchema = z.object({
configId: uuidSchema.optional(),
branchId: uuidSchema.optional(),
receptor: receptorSchema,
lines: z.array(orderLineSchema).min(1, 'At least one line item is required'),
formaPago: formaPagoSchema,
metodoPago: metodoPagoSchema,
moneda: monedaSchema.optional(),
tipoCambio: z.number().positive().optional(),
condicionesPago: z.string().max(100).optional(),
orderId: uuidSchema.optional(),
orderNumber: z.string().max(30).optional(),
orderType: z.enum(['pos', 'ecommerce']).optional(),
notes: z.string().max(1000).optional(),
stampImmediately: z.boolean().optional().default(true),
});
// ==================== CANCEL CFDI SCHEMA ====================
export const cancelCFDISchema = z.object({
reason: cancellationReasonEnum,
substituteUuid: z.string().uuid().optional(),
}).refine(
(data) => {
// If reason is 01, substituteUuid is required
if (data.reason === '01' && !data.substituteUuid) {
return false;
}
return true;
},
{
message: 'Substitute UUID is required for cancellation reason 01',
path: ['substituteUuid'],
}
);
// ==================== LIST CFDI QUERY SCHEMA ====================
export const listCFDIsQuerySchema = paginationSchema.extend({
status: cfdiStatusEnum.optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
receptorRfc: rfcSchema.optional(),
branchId: uuidSchema.optional(),
search: z.string().max(100).optional(),
});
// ==================== RESEND EMAIL SCHEMA ====================
export const resendEmailSchema = z.object({
email: z.string().email().max(100).optional(),
});
// ==================== CREATE CREDIT NOTE SCHEMA ====================
export const createCreditNoteSchema = z.object({
originalCfdiId: uuidSchema,
lines: z.array(orderLineSchema).min(1),
reason: z.string().min(1).max(500),
});
// ==================== CFDI CONFIG SCHEMAS ====================
export const pacProviderEnum = z.enum([
'finkok',
'soluciones_fiscales',
'facturapi',
'sw_sapien',
'digisat',
'other',
]);
export const cfdiConfigStatusEnum = z.enum([
'active',
'inactive',
'pending_validation',
'expired',
]);
export const createCFDIConfigSchema = z.object({
branchId: uuidSchema.optional(),
rfc: rfcSchema,
razonSocial: z.string().min(1).max(300),
nombreComercial: z.string().max(200).optional(),
regimenFiscal: regimenFiscalSchema,
regimenFiscalDescripcion: z.string().max(200).optional(),
codigoPostal: codigoPostalSchema,
domicilio: z.string().max(500).optional(),
// PAC configuration
pacProvider: pacProviderEnum.default('finkok'),
pacUsername: z.string().max(100).optional(),
pacPassword: z.string().max(255).optional(),
pacEnvironment: z.enum(['sandbox', 'production']).default('sandbox'),
pacApiUrl: z.string().url().max(255).optional(),
// Certificate (base64 encoded)
certificate: z.string().optional(),
privateKey: z.string().optional(),
certificatePassword: z.string().max(255).optional(),
// Defaults
defaultSerie: z.string().min(1).max(10).default('A'),
defaultFormaPago: formaPagoSchema.default('01'),
defaultMetodoPago: metodoPagoSchema.default('PUE'),
defaultUsoCFDI: usoCFDISchema.default('G03'),
defaultMoneda: monedaSchema.default('MXN'),
// Branding
logoUrl: z.string().url().max(255).optional(),
pdfTemplate: z.string().max(50).default('standard'),
footerText: z.string().max(500).optional(),
// Email settings
sendEmailOnIssue: z.boolean().default(true),
emailFrom: z.string().email().max(100).optional(),
emailSubjectTemplate: z.string().max(200).optional(),
emailBodyTemplate: z.string().max(2000).optional(),
// Notifications
notifyCertificateExpiry: z.boolean().default(true),
notifyDaysBeforeExpiry: z.number().int().min(1).max(90).default(30),
// Auto-invoicing
autoInvoicePOS: z.boolean().default(false),
autoInvoiceEcommerce: z.boolean().default(true),
// Rate limiting
maxInvoicesPerDay: z.number().int().positive().optional(),
});
export const updateCFDIConfigSchema = createCFDIConfigSchema.partial();
export const validateCFDIConfigSchema = z.object({
testStamp: z.boolean().default(false),
});
// ==================== AUTOFACTURA SCHEMAS ====================
export const autofacturaRequestSchema = z.object({
orderNumber: z.string().min(1).max(30),
orderDate: z.coerce.date().optional(),
branchId: uuidSchema.optional(),
receptor: receptorSchema,
});
export const lookupOrderSchema = z.object({
orderNumber: z.string().min(1).max(30),
orderDate: z.coerce.date().optional(),
orderAmount: moneySchema.positive().optional(),
});
// ==================== STATS QUERY SCHEMA ====================
export const cfdiStatsQuerySchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
branchId: uuidSchema.optional(),
});
// ==================== TYPES ====================
export type CreateCFDIInput = z.infer<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>;

View File

@ -0,0 +1 @@
export * from './cfdi.schema';

View File

@ -0,0 +1,605 @@
import { Response, NextFunction } from 'express';
import { BaseController } from '../../../shared/controllers/base.controller';
import { AuthenticatedRequest } from '../../../shared/types';
import { posSessionService } from '../services/pos-session.service';
import { posOrderService } from '../services/pos-order.service';
class POSController extends BaseController {
// ==================== SESSION ENDPOINTS ====================
/**
* POST /pos/sessions/open - Open a new session
*/
async openSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<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();

View File

@ -0,0 +1,4 @@
export * from './pos-session.entity';
export * from './pos-order.entity';
export * from './pos-order-line.entity';
export * from './pos-payment.entity';

View File

@ -0,0 +1,173 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { POSOrder } from './pos-order.entity';
export enum LineStatus {
ACTIVE = 'active',
VOIDED = 'voided',
RETURNED = 'returned',
}
@Entity('pos_order_lines', { schema: 'retail' })
@Index(['tenantId', 'orderId'])
@Index(['tenantId', 'productId'])
export class POSOrderLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'order_id', type: 'uuid' })
orderId: string;
@Column({ name: 'line_number', type: 'int' })
lineNumber: number;
// Product info (from erp-core inventory)
@Column({ name: 'product_id', type: 'uuid' })
productId: string;
@Column({ name: 'product_code', length: 50 })
productCode: string;
@Column({ name: 'product_name', length: 200 })
productName: string;
@Column({ name: 'product_barcode', length: 50, nullable: true })
productBarcode: string;
// Variant info (if applicable)
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
variantId: string;
@Column({ name: 'variant_name', length: 200, nullable: true })
variantName: string;
// Quantity
@Column({ type: 'decimal', precision: 15, scale: 4, default: 1 })
quantity: number;
@Column({ name: 'uom_id', type: 'uuid', nullable: true })
uomId: string;
@Column({ name: 'uom_name', length: 20, default: 'PZA' })
uomName: string;
// Pricing
@Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 })
unitPrice: number;
@Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 })
originalPrice: number;
@Column({ name: 'price_list_id', type: 'uuid', nullable: true })
priceListId: string;
// Line discounts
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPercent: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
discountAmount: number;
@Column({ name: 'discount_reason', length: 255, nullable: true })
discountReason: string;
// Promotion applied
@Column({ name: 'promotion_id', type: 'uuid', nullable: true })
promotionId: string;
@Column({ name: 'promotion_name', length: 100, nullable: true })
promotionName: string;
@Column({ name: 'promotion_discount', type: 'decimal', precision: 15, scale: 2, default: 0 })
promotionDiscount: number;
// Tax
@Column({ name: 'tax_id', type: 'uuid', nullable: true })
taxId: string;
@Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 })
taxRate: number;
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
taxAmount: number;
@Column({ name: 'tax_included', type: 'boolean', default: true })
taxIncluded: boolean;
// Totals
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
subtotal: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
total: number;
// Cost (for margin calculation)
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true })
unitCost: number;
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: true })
margin: number;
// Status
@Column({
type: 'enum',
enum: LineStatus,
default: LineStatus.ACTIVE,
})
status: LineStatus;
// Lot/Serial tracking
@Column({ name: 'lot_number', length: 50, nullable: true })
lotNumber: string;
@Column({ name: 'serial_number', length: 50, nullable: true })
serialNumber: string;
@Column({ name: 'expiry_date', type: 'date', nullable: true })
expiryDate: Date;
// Warehouse location
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId: string;
@Column({ name: 'location_id', type: 'uuid', nullable: true })
locationId: string;
// For returns/exchanges
@Column({ name: 'original_line_id', type: 'uuid', nullable: true })
originalLineId: string;
@Column({ name: 'return_reason', length: 255, nullable: true })
returnReason: string;
// Salesperson override (if different from order)
@Column({ name: 'salesperson_id', type: 'uuid', nullable: true })
salespersonId: string;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => POSOrder, (order) => order.lines)
@JoinColumn({ name: 'order_id' })
order: POSOrder;
}

View File

@ -0,0 +1,223 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { POSSession } from './pos-session.entity';
import { POSOrderLine } from './pos-order-line.entity';
import { POSPayment } from './pos-payment.entity';
export enum OrderStatus {
DRAFT = 'draft',
CONFIRMED = 'confirmed',
PAID = 'paid',
PARTIALLY_PAID = 'partially_paid',
VOIDED = 'voided',
REFUNDED = 'refunded',
}
export enum OrderType {
SALE = 'sale',
REFUND = 'refund',
EXCHANGE = 'exchange',
}
@Entity('pos_orders', { schema: 'retail' })
@Index(['tenantId', 'branchId', 'createdAt'])
@Index(['tenantId', 'sessionId'])
@Index(['tenantId', 'customerId'])
@Index(['tenantId', 'number'], { unique: true })
export class POSOrder {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@Column({ name: 'session_id', type: 'uuid' })
sessionId: string;
@Column({ name: 'register_id', type: 'uuid' })
registerId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ length: 30, unique: true })
number: string;
@Column({
type: 'enum',
enum: OrderType,
default: OrderType.SALE,
})
type: OrderType;
@Column({
type: 'enum',
enum: OrderStatus,
default: OrderStatus.DRAFT,
})
status: OrderStatus;
// Customer (optional, from erp-core partners)
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId: string;
@Column({ name: 'customer_name', length: 200, nullable: true })
customerName: string;
@Column({ name: 'customer_rfc', length: 13, nullable: true })
customerRfc: string;
// Salesperson (for commission tracking)
@Column({ name: 'salesperson_id', type: 'uuid', nullable: true })
salespersonId: string;
// Amounts
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
subtotal: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
discountAmount: number;
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPercent: number;
@Column({ name: 'discount_reason', length: 255, nullable: true })
discountReason: string;
@Column({ name: 'discount_authorized_by', type: 'uuid', nullable: true })
discountAuthorizedBy: string;
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
taxAmount: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
total: number;
@Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 })
amountPaid: number;
@Column({ name: 'change_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
changeAmount: number;
// Tax breakdown
@Column({ name: 'tax_breakdown', type: 'jsonb', nullable: true })
taxBreakdown: {
taxId: string;
taxName: string;
rate: number;
base: number;
amount: number;
}[];
// Currency
@Column({ name: 'currency_code', length: 3, default: 'MXN' })
currencyCode: string;
@Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 })
exchangeRate: number;
// Coupon/Promotion
@Column({ name: 'coupon_id', type: 'uuid', nullable: true })
couponId: string;
@Column({ name: 'coupon_code', length: 50, nullable: true })
couponCode: string;
@Column({ name: 'promotion_ids', type: 'jsonb', nullable: true })
promotionIds: string[];
// Loyalty points
@Column({ name: 'loyalty_points_earned', type: 'int', default: 0 })
loyaltyPointsEarned: number;
@Column({ name: 'loyalty_points_redeemed', type: 'int', default: 0 })
loyaltyPointsRedeemed: number;
@Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 })
loyaltyPointsValue: number;
// Invoicing
@Column({ name: 'requires_invoice', type: 'boolean', default: false })
requiresInvoice: boolean;
@Column({ name: 'cfdi_id', type: 'uuid', nullable: true })
cfdiId: string;
@Column({ name: 'invoice_status', length: 20, nullable: true })
invoiceStatus: 'pending' | 'issued' | 'cancelled';
// Reference to original order (for refunds/exchanges)
@Column({ name: 'original_order_id', type: 'uuid', nullable: true })
originalOrderId: string;
// Void info
@Column({ name: 'voided_at', type: 'timestamp with time zone', nullable: true })
voidedAt: Date;
@Column({ name: 'voided_by', type: 'uuid', nullable: true })
voidedBy: string;
@Column({ name: 'void_reason', length: 255, nullable: true })
voidReason: string;
// Notes
@Column({ type: 'text', nullable: true })
notes: string;
// Offline sync
@Column({ name: 'is_offline_order', type: 'boolean', default: false })
isOfflineOrder: boolean;
@Column({ name: 'offline_uuid', type: 'uuid', nullable: true })
offlineUuid: string;
@Column({ name: 'synced_at', type: 'timestamp with time zone', nullable: true })
syncedAt: Date;
// Receipt
@Column({ name: 'receipt_printed', type: 'boolean', default: false })
receiptPrinted: boolean;
@Column({ name: 'receipt_sent', type: 'boolean', default: false })
receiptSent: boolean;
@Column({ name: 'receipt_email', length: 100, nullable: true })
receiptEmail: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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[];
}

View File

@ -0,0 +1,173 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { POSOrder } from './pos-order.entity';
export enum PaymentMethod {
CASH = 'cash',
DEBIT_CARD = 'debit_card',
CREDIT_CARD = 'credit_card',
TRANSFER = 'transfer',
CHECK = 'check',
LOYALTY_POINTS = 'loyalty_points',
VOUCHER = 'voucher',
OTHER = 'other',
}
export enum PaymentStatus {
PENDING = 'pending',
COMPLETED = 'completed',
FAILED = 'failed',
REFUNDED = 'refunded',
VOIDED = 'voided',
}
@Entity('pos_payments', { schema: 'retail' })
@Index(['tenantId', 'orderId'])
@Index(['tenantId', 'sessionId'])
@Index(['tenantId', 'createdAt'])
export class POSPayment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'order_id', type: 'uuid' })
orderId: string;
@Column({ name: 'session_id', type: 'uuid' })
sessionId: string;
@Column({
type: 'enum',
enum: PaymentMethod,
})
method: PaymentMethod;
@Column({
type: 'enum',
enum: PaymentStatus,
default: PaymentStatus.PENDING,
})
status: PaymentStatus;
// Amount
@Column({ type: 'decimal', precision: 15, scale: 2 })
amount: number;
@Column({ name: 'amount_received', type: 'decimal', precision: 15, scale: 2 })
amountReceived: number;
@Column({ name: 'change_given', type: 'decimal', precision: 15, scale: 2, default: 0 })
changeGiven: number;
// Currency
@Column({ name: 'currency_code', length: 3, default: 'MXN' })
currencyCode: string;
@Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 })
exchangeRate: number;
// Card payment details
@Column({ name: 'card_type', length: 20, nullable: true })
cardType: string; // visa, mastercard, amex, etc.
@Column({ name: 'card_last_four', length: 4, nullable: true })
cardLastFour: string;
@Column({ name: 'card_holder', length: 100, nullable: true })
cardHolder: string;
@Column({ name: 'authorization_code', length: 50, nullable: true })
authorizationCode: string;
@Column({ name: 'transaction_reference', length: 100, nullable: true })
transactionReference: string;
@Column({ name: 'terminal_id', length: 50, nullable: true })
terminalId: string;
// Transfer details
@Column({ name: 'bank_name', length: 100, nullable: true })
bankName: string;
@Column({ name: 'transfer_reference', length: 100, nullable: true })
transferReference: string;
// Check details
@Column({ name: 'check_number', length: 50, nullable: true })
checkNumber: string;
@Column({ name: 'check_bank', length: 100, nullable: true })
checkBank: string;
@Column({ name: 'check_date', type: 'date', nullable: true })
checkDate: Date;
// Voucher/Gift card
@Column({ name: 'voucher_code', length: 50, nullable: true })
voucherCode: string;
@Column({ name: 'voucher_id', type: 'uuid', nullable: true })
voucherId: string;
// Loyalty points
@Column({ name: 'loyalty_points_used', type: 'int', nullable: true })
loyaltyPointsUsed: number;
// Tips
@Column({ name: 'tip_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
tipAmount: number;
// Financial account (for accounting integration)
@Column({ name: 'account_id', type: 'uuid', nullable: true })
accountId: string;
// Refund reference
@Column({ name: 'refund_of_payment_id', type: 'uuid', nullable: true })
refundOfPaymentId: string;
@Column({ name: 'refund_reason', length: 255, nullable: true })
refundReason: string;
// Processing info
@Column({ name: 'processed_at', type: 'timestamp with time zone', nullable: true })
processedAt: Date;
@Column({ name: 'processed_by', type: 'uuid', nullable: true })
processedBy: string;
// External payment gateway
@Column({ name: 'gateway_name', length: 50, nullable: true })
gatewayName: string;
@Column({ name: 'gateway_transaction_id', length: 100, nullable: true })
gatewayTransactionId: string;
@Column({ name: 'gateway_response', type: 'jsonb', nullable: true })
gatewayResponse: Record<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;
}

View File

@ -0,0 +1,146 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { POSOrder } from './pos-order.entity';
export enum SessionStatus {
OPEN = 'open',
CLOSING = 'closing',
CLOSED = 'closed',
RECONCILED = 'reconciled',
}
@Entity('pos_sessions', { schema: 'retail' })
@Index(['tenantId', 'branchId', 'status'])
@Index(['tenantId', 'registerId', 'status'])
@Index(['tenantId', 'openedAt'])
export class POSSession {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@Column({ name: 'register_id', type: 'uuid' })
registerId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ length: 30, unique: true })
number: string;
@Column({
type: 'enum',
enum: SessionStatus,
default: SessionStatus.OPEN,
})
status: SessionStatus;
// Opening
@Column({ name: 'opened_at', type: 'timestamp with time zone' })
openedAt: Date;
@Column({ name: 'opening_cash', type: 'decimal', precision: 15, scale: 2, default: 0 })
openingCash: number;
@Column({ name: 'opening_notes', type: 'text', nullable: true })
openingNotes: string;
// Closing
@Column({ name: 'closed_at', type: 'timestamp with time zone', nullable: true })
closedAt: Date;
@Column({ name: 'closing_cash_counted', type: 'decimal', precision: 15, scale: 2, nullable: true })
closingCashCounted: number;
@Column({ name: 'closing_cash_expected', type: 'decimal', precision: 15, scale: 2, nullable: true })
closingCashExpected: number;
@Column({ name: 'closing_difference', type: 'decimal', precision: 15, scale: 2, nullable: true })
closingDifference: number;
@Column({ name: 'closing_notes', type: 'text', nullable: true })
closingNotes: string;
@Column({ name: 'closed_by', type: 'uuid', nullable: true })
closedBy: string;
// Session totals (denormalized for performance)
@Column({ name: 'total_sales', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalSales: number;
@Column({ name: 'total_refunds', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalRefunds: number;
@Column({ name: 'total_discounts', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalDiscounts: number;
@Column({ name: 'orders_count', type: 'int', default: 0 })
ordersCount: number;
@Column({ name: 'items_sold', type: 'int', default: 0 })
itemsSold: number;
// Payment totals by method
@Column({ name: 'cash_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
cashTotal: number;
@Column({ name: 'card_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
cardTotal: number;
@Column({ name: 'transfer_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
transferTotal: number;
@Column({ name: 'other_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
otherTotal: number;
// Cash movements during session
@Column({ name: 'cash_in_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
cashInTotal: number;
@Column({ name: 'cash_out_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
cashOutTotal: number;
// Detailed cash count (closing)
@Column({ name: 'cash_count_detail', type: 'jsonb', nullable: true })
cashCountDetail: {
bills: { denomination: number; count: number; total: number }[];
coins: { denomination: number; count: number; total: number }[];
total: number;
};
// Offline sync
@Column({ name: 'is_offline_session', type: 'boolean', default: false })
isOfflineSession: boolean;
@Column({ name: 'synced_at', type: 'timestamp with time zone', nullable: true })
syncedAt: Date;
@Column({ name: 'device_uuid', type: 'uuid', nullable: true })
deviceUuid: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => POSOrder, (order) => order.session)
orders: POSOrder[];
}

3
src/modules/pos/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './entities';
export * from './services';
export { default as posRoutes } from './routes/pos.routes';

View File

@ -0,0 +1,143 @@
import { Router } from 'express';
import { posController } from '../controllers/pos.controller';
import { authMiddleware, requireRoles } from '../../../shared/middleware/auth.middleware';
import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware';
import { branchMiddleware, validateBranchMiddleware, requireBranchCapability } from '../../../shared/middleware/branch.middleware';
import { AuthenticatedRequest } from '../../../shared/types';
const router = Router();
// All routes require tenant and authentication
router.use(tenantMiddleware);
router.use(authMiddleware);
// ==================== SESSION ROUTES ====================
// Get active session for current user (no branch required)
router.get('/sessions/active', (req, res, next) =>
posController.getActiveSession(req as AuthenticatedRequest, res, next)
);
// Routes that require branch context
router.use('/sessions', branchMiddleware);
router.use('/sessions', validateBranchMiddleware);
router.use('/sessions', requireBranchCapability('pos'));
// Open session
router.post('/sessions/open', (req, res, next) =>
posController.openSession(req as AuthenticatedRequest, res, next)
);
// Close session
router.post('/sessions/:id/close', (req, res, next) =>
posController.closeSession(req as AuthenticatedRequest, res, next)
);
// Get daily summary
router.get('/sessions/daily-summary', (req, res, next) =>
posController.getDailySummary(req as AuthenticatedRequest, res, next)
);
// List sessions
router.get('/sessions', (req, res, next) =>
posController.listSessions(req as AuthenticatedRequest, res, next)
);
// Get session by ID
router.get('/sessions/:id', (req, res, next) =>
posController.getSession(req as AuthenticatedRequest, res, next)
);
// ==================== ORDER ROUTES ====================
// Routes that require branch context
router.use('/orders', branchMiddleware);
router.use('/orders', validateBranchMiddleware);
router.use('/orders', requireBranchCapability('pos'));
// Create order
router.post('/orders', (req, res, next) =>
posController.createOrder(req as AuthenticatedRequest, res, next)
);
// List orders
router.get('/orders', (req, res, next) =>
posController.listOrders(req as AuthenticatedRequest, res, next)
);
// Get orders by session
router.get('/orders/session/:sessionId', (req, res, next) =>
posController.getOrdersBySession(req as AuthenticatedRequest, res, next)
);
// Get order by ID
router.get('/orders/:id', (req, res, next) =>
posController.getOrder(req as AuthenticatedRequest, res, next)
);
// Add payment
router.post('/orders/:id/payments', (req, res, next) =>
posController.addPayment(req as AuthenticatedRequest, res, next)
);
// Void order (requires supervisor or higher)
router.post(
'/orders/:id/void',
requireRoles('admin', 'manager', 'supervisor'),
(req, res, next) => posController.voidOrder(req as AuthenticatedRequest, res, next)
);
// Hold order
router.post('/orders/:id/hold', (req, res, next) =>
posController.holdOrder(req as AuthenticatedRequest, res, next)
);
// Get held orders
router.get('/orders/held', (req, res, next) =>
posController.getHeldOrders(req as AuthenticatedRequest, res, next)
);
// Recall held order
router.post('/orders/:id/recall', (req, res, next) =>
posController.recallOrder(req as AuthenticatedRequest, res, next)
);
// Apply coupon to order
router.post('/orders/:id/coupon', (req, res, next) =>
posController.applyCoupon(req as AuthenticatedRequest, res, next)
);
// Mark receipt as printed
router.post('/orders/:id/receipt/print', (req, res, next) =>
posController.markReceiptPrinted(req as AuthenticatedRequest, res, next)
);
// Send receipt by email
router.post('/orders/:id/receipt/send', (req, res, next) =>
posController.sendReceipt(req as AuthenticatedRequest, res, next)
);
// ==================== REFUND ROUTES ====================
// Create refund
router.post(
'/refunds',
requireRoles('admin', 'manager', 'supervisor'),
(req, res, next) => posController.createRefund(req as AuthenticatedRequest, res, next)
);
// Process refund payment
router.post(
'/refunds/:id/process',
requireRoles('admin', 'manager', 'supervisor'),
(req, res, next) => posController.processRefund(req as AuthenticatedRequest, res, next)
);
// ==================== SESSION STATS ====================
// Get session statistics
router.get('/sessions/:id/stats', (req, res, next) =>
posController.getSessionStats(req as AuthenticatedRequest, res, next)
);
export default router;

View File

@ -0,0 +1,2 @@
export * from './pos-session.service';
export * from './pos-order.service';

View File

@ -0,0 +1,914 @@
import { Repository, DeepPartial } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult } from '../../../shared/types';
import { POSOrder, OrderStatus, OrderType } from '../entities/pos-order.entity';
import { POSOrderLine, LineStatus } from '../entities/pos-order-line.entity';
import { POSPayment, PaymentMethod, PaymentStatus } from '../entities/pos-payment.entity';
import { posSessionService } from './pos-session.service';
interface CreateOrderDTO {
sessionId: string;
branchId: string;
registerId: string;
userId: string;
customerId?: string;
customerName?: string;
customerRfc?: string;
lines: {
productId: string;
productCode: string;
productName: string;
productBarcode?: string;
quantity: number;
unitPrice: number;
originalPrice: number;
discountPercent?: number;
discountAmount?: number;
taxRate: number;
taxIncluded?: boolean;
}[];
discountPercent?: number;
discountAmount?: number;
discountReason?: string;
requiresInvoice?: boolean;
notes?: string;
}
interface AddPaymentDTO {
method: PaymentMethod;
amount: number;
amountReceived: number;
cardType?: string;
cardLastFour?: string;
authorizationCode?: string;
transactionReference?: string;
}
export class POSOrderService extends BaseService<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();

View File

@ -0,0 +1,360 @@
import { Repository, DeepPartial } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult } from '../../../shared/types';
import { POSSession, SessionStatus } from '../entities/pos-session.entity';
import { CashRegister, RegisterStatus } from '../../branches/entities/cash-register.entity';
export class POSSessionService extends BaseService<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();

View File

@ -0,0 +1,206 @@
import { z } from 'zod';
import {
uuidSchema,
moneySchema,
quantitySchema,
percentSchema,
paginationSchema,
rfcSchema,
emailSchema,
} from '../../../shared/validation/common.schema';
// Enums
export const paymentMethodEnum = z.enum([
'cash',
'debit_card',
'credit_card',
'transfer',
'check',
'loyalty_points',
'voucher',
'other',
]);
export const orderStatusEnum = z.enum([
'draft',
'confirmed',
'paid',
'partially_paid',
'voided',
'refunded',
]);
// ==================== SESSION SCHEMAS ====================
// Open session schema
export const openSessionSchema = z.object({
registerId: uuidSchema,
openingCash: moneySchema.default(0),
openingNotes: z.string().max(500).optional(),
});
// Close session schema
export const closeSessionSchema = z.object({
closingCashCounted: moneySchema,
closingNotes: z.string().max(500).optional(),
cashCountDetail: z.object({
bills: z.array(z.object({
denomination: z.number(),
count: z.number().int().min(0),
total: z.number(),
})).optional(),
coins: z.array(z.object({
denomination: z.number(),
count: z.number().int().min(0),
total: z.number(),
})).optional(),
total: z.number(),
}).optional(),
});
// List sessions query schema
export const listSessionsQuerySchema = paginationSchema.extend({
status: z.enum(['open', 'closing', 'closed', 'reconciled']).optional(),
userId: uuidSchema.optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
});
// ==================== ORDER SCHEMAS ====================
// Order line schema
export const orderLineSchema = z.object({
productId: uuidSchema,
productCode: z.string().min(1).max(50),
productName: z.string().min(1).max(200),
productBarcode: z.string().max(50).optional(),
variantId: uuidSchema.optional(),
variantName: z.string().max(200).optional(),
quantity: quantitySchema.min(0.0001),
unitPrice: moneySchema,
originalPrice: moneySchema,
discountPercent: percentSchema.optional(),
discountAmount: moneySchema.optional(),
taxRate: z.coerce.number().min(0).max(1).default(0.16),
taxIncluded: z.boolean().default(true),
notes: z.string().max(500).optional(),
});
// Create order schema
export const createOrderSchema = z.object({
sessionId: uuidSchema,
registerId: uuidSchema,
customerId: uuidSchema.optional(),
customerName: z.string().max(200).optional(),
customerRfc: rfcSchema.optional(),
salespersonId: uuidSchema.optional(),
lines: z.array(orderLineSchema).min(1, 'At least one line is required'),
discountPercent: percentSchema.optional(),
discountAmount: moneySchema.optional(),
discountReason: z.string().max(255).optional(),
couponId: uuidSchema.optional(),
couponCode: z.string().max(50).optional(),
requiresInvoice: z.boolean().default(false),
notes: z.string().max(1000).optional(),
});
// Add payment schema
export const addPaymentSchema = z.object({
method: paymentMethodEnum,
amount: moneySchema.positive('Amount must be positive'),
amountReceived: moneySchema.optional(),
cardType: z.string().max(20).optional(),
cardLastFour: z.string().length(4).optional(),
cardHolder: z.string().max(100).optional(),
authorizationCode: z.string().max(50).optional(),
transactionReference: z.string().max(100).optional(),
terminalId: z.string().max(50).optional(),
bankName: z.string().max(100).optional(),
transferReference: z.string().max(100).optional(),
checkNumber: z.string().max(50).optional(),
checkBank: z.string().max(100).optional(),
voucherCode: z.string().max(50).optional(),
loyaltyPointsUsed: z.number().int().min(0).optional(),
tipAmount: moneySchema.optional(),
notes: z.string().max(500).optional(),
});
// Void order schema
export const voidOrderSchema = z.object({
reason: z.string().min(1, 'Reason is required').max(255),
});
// List orders query schema
export const listOrdersQuerySchema = paginationSchema.extend({
sessionId: uuidSchema.optional(),
status: orderStatusEnum.optional(),
customerId: uuidSchema.optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
search: z.string().optional(),
});
// ==================== RECEIPT SCHEMAS ====================
// Send receipt schema
export const sendReceiptSchema = z.object({
email: emailSchema,
});
// ==================== REFUND SCHEMAS ====================
// Refund line schema
export const refundLineSchema = z.object({
originalLineId: uuidSchema,
quantity: quantitySchema.positive('Quantity must be positive'),
reason: z.string().min(1).max(255),
});
// Create refund schema
export const createRefundSchema = z.object({
originalOrderId: uuidSchema,
sessionId: uuidSchema,
registerId: uuidSchema,
lines: z.array(refundLineSchema).min(1, 'At least one line is required'),
refundReason: z.string().min(1).max(500),
});
// Process refund payment schema
export const processRefundPaymentSchema = z.object({
method: paymentMethodEnum,
amount: moneySchema.positive('Amount must be positive'),
});
// ==================== HOLD/RECALL SCHEMAS ====================
// Hold order schema
export const holdOrderSchema = z.object({
holdName: z.string().max(100).optional(),
});
// Recall order schema
export const recallOrderSchema = z.object({
sessionId: uuidSchema,
registerId: uuidSchema,
});
// ==================== COUPON SCHEMA ====================
// Apply coupon schema
export const applyCouponSchema = z.object({
couponCode: z.string().min(1).max(50),
});
// Types
export type OpenSessionInput = z.infer<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>;

View File

@ -0,0 +1 @@
export * from './pricing.controller';

View File

@ -0,0 +1,985 @@
import { Request, Response, NextFunction } from 'express';
import { PriceEngineService } from '../services/price-engine.service';
import { PromotionService } from '../services/promotion.service';
import { CouponService } from '../services/coupon.service';
import {
CreatePromotionInput,
UpdatePromotionInput,
CreateCouponInput,
UpdateCouponInput,
CalculatePricesInput,
RedeemCouponInput,
ListPromotionsQuery,
ListCouponsQuery,
PromotionProductInput,
GenerateBulkCouponsInput,
} from '../validation/pricing.schema';
import { AppDataSource } from '../../../config/database';
import { Promotion } from '../entities/promotion.entity';
import { Coupon } from '../entities/coupon.entity';
const priceEngineService = new PriceEngineService(AppDataSource);
const promotionService = new PromotionService(
AppDataSource,
AppDataSource.getRepository(Promotion)
);
const couponService = new CouponService(
AppDataSource,
AppDataSource.getRepository(Coupon)
);
// ==================== PRICE ENGINE ====================
/**
* Calculate prices for cart/order items
* POST /api/pricing/calculate
*/
export const calculatePrices = async (
req: Request,
res: Response,
next: NextFunction
): Promise<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);
}
};

View File

@ -0,0 +1,116 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Coupon } from './coupon.entity';
export enum RedemptionStatus {
APPLIED = 'applied',
COMPLETED = 'completed',
REVERSED = 'reversed',
CANCELLED = 'cancelled',
}
@Entity('coupon_redemptions', { schema: 'retail' })
@Index(['tenantId', 'couponId', 'createdAt'])
@Index(['tenantId', 'customerId'])
@Index(['tenantId', 'orderId'])
export class CouponRedemption {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'coupon_id', type: 'uuid' })
couponId: string;
@Column({ name: 'coupon_code', length: 50 })
couponCode: string;
@Column({ name: 'order_id', type: 'uuid' })
orderId: string;
@Column({ name: 'order_number', length: 30 })
orderNumber: string;
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId: string;
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@Column({
type: 'enum',
enum: RedemptionStatus,
default: RedemptionStatus.APPLIED,
})
status: RedemptionStatus;
// Amounts
@Column({ name: 'order_amount', type: 'decimal', precision: 15, scale: 2 })
orderAmount: number;
@Column({ name: 'discount_applied', type: 'decimal', precision: 15, scale: 2 })
discountApplied: number;
// Discount breakdown
@Column({ name: 'discount_type', length: 30 })
discountType: string;
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
discountPercent: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
discountAmount: number;
// Free product info
@Column({ name: 'free_product_id', type: 'uuid', nullable: true })
freeProductId: string;
@Column({ name: 'free_product_quantity', type: 'int', nullable: true })
freeProductQuantity: number;
@Column({ name: 'free_product_value', type: 'decimal', precision: 15, scale: 2, nullable: true })
freeProductValue: number;
// Channel
@Column({ length: 20, default: 'pos' })
channel: 'pos' | 'ecommerce' | 'mobile';
// Reversal info
@Column({ name: 'reversed_at', type: 'timestamp with time zone', nullable: true })
reversedAt: Date;
@Column({ name: 'reversed_by', type: 'uuid', nullable: true })
reversedBy: string;
@Column({ name: 'reversal_reason', length: 255, nullable: true })
reversalReason: string;
// User who applied the coupon
@Column({ name: 'applied_by', type: 'uuid' })
appliedBy: string;
// IP address (for e-commerce fraud prevention)
@Column({ name: 'ip_address', length: 45, nullable: true })
ipAddress: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => Coupon, (coupon) => coupon.redemptions)
@JoinColumn({ name: 'coupon_id' })
coupon: Coupon;
}

View File

@ -0,0 +1,195 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { CouponRedemption } from './coupon-redemption.entity';
export enum CouponStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
EXPIRED = 'expired',
DEPLETED = 'depleted',
}
export enum CouponType {
PERCENTAGE = 'percentage',
FIXED_AMOUNT = 'fixed_amount',
FREE_SHIPPING = 'free_shipping',
FREE_PRODUCT = 'free_product',
}
export enum CouponScope {
ORDER = 'order',
PRODUCT = 'product',
CATEGORY = 'category',
SHIPPING = 'shipping',
}
@Entity('coupons', { schema: 'retail' })
@Index(['tenantId', 'code'], { unique: true })
@Index(['tenantId', 'status', 'validFrom', 'validUntil'])
@Index(['tenantId', 'campaignId'])
export class Coupon {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ length: 50, unique: true })
code: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({
type: 'enum',
enum: CouponType,
})
type: CouponType;
@Column({
type: 'enum',
enum: CouponScope,
default: CouponScope.ORDER,
})
scope: CouponScope;
@Column({
type: 'enum',
enum: CouponStatus,
default: CouponStatus.ACTIVE,
})
status: CouponStatus;
// Discount value
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
discountPercent: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
discountAmount: number;
@Column({ name: 'max_discount', type: 'decimal', precision: 15, scale: 2, nullable: true })
maxDiscount: number;
// Free product
@Column({ name: 'free_product_id', type: 'uuid', nullable: true })
freeProductId: string;
@Column({ name: 'free_product_quantity', type: 'int', default: 1 })
freeProductQuantity: number;
// Validity
@Column({ name: 'valid_from', type: 'timestamp with time zone' })
validFrom: Date;
@Column({ name: 'valid_until', type: 'timestamp with time zone', nullable: true })
validUntil: Date;
// Usage limits
@Column({ name: 'max_uses', type: 'int', nullable: true })
maxUses: number;
@Column({ name: 'max_uses_per_customer', type: 'int', default: 1 })
maxUsesPerCustomer: number;
@Column({ name: 'current_uses', type: 'int', default: 0 })
currentUses: number;
// Minimum requirements
@Column({ name: 'min_order_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
minOrderAmount: number;
@Column({ name: 'min_items', type: 'int', nullable: true })
minItems: number;
// Scope restrictions
@Column({ name: 'applies_to_categories', type: 'jsonb', nullable: true })
appliesToCategories: string[];
@Column({ name: 'applies_to_products', type: 'jsonb', nullable: true })
appliesToProducts: string[];
@Column({ name: 'excluded_products', type: 'jsonb', nullable: true })
excludedProducts: string[];
@Column({ name: 'included_branches', type: 'jsonb', nullable: true })
includedBranches: string[];
// Customer restrictions
@Column({ name: 'customer_specific', type: 'boolean', default: false })
customerSpecific: boolean;
@Column({ name: 'allowed_customers', type: 'jsonb', nullable: true })
allowedCustomers: string[];
@Column({ name: 'customer_levels', type: 'jsonb', nullable: true })
customerLevels: string[];
@Column({ name: 'new_customers_only', type: 'boolean', default: false })
newCustomersOnly: boolean;
@Column({ name: 'first_order_only', type: 'boolean', default: false })
firstOrderOnly: boolean;
// Campaign
@Column({ name: 'campaign_id', type: 'uuid', nullable: true })
campaignId: string;
@Column({ name: 'campaign_name', length: 100, nullable: true })
campaignName: string;
// Stackability
@Column({ name: 'stackable_with_promotions', type: 'boolean', default: false })
stackableWithPromotions: boolean;
@Column({ name: 'stackable_with_coupons', type: 'boolean', default: false })
stackableWithCoupons: boolean;
// Channels
@Column({ name: 'pos_enabled', type: 'boolean', default: true })
posEnabled: boolean;
@Column({ name: 'ecommerce_enabled', type: 'boolean', default: true })
ecommerceEnabled: boolean;
// Distribution
@Column({ name: 'is_single_use', type: 'boolean', default: false })
isSingleUse: boolean;
@Column({ name: 'auto_apply', type: 'boolean', default: false })
autoApply: boolean;
@Column({ name: 'public_visible', type: 'boolean', default: false })
publicVisible: boolean;
// Terms
@Column({ name: 'terms_and_conditions', type: 'text', nullable: true })
termsAndConditions: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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[];
}

View File

@ -0,0 +1,4 @@
export * from './promotion.entity';
export * from './promotion-product.entity';
export * from './coupon.entity';
export * from './coupon-redemption.entity';

View File

@ -0,0 +1,91 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Promotion } from './promotion.entity';
export enum ProductRole {
TRIGGER = 'trigger', // Must be purchased to activate promotion
TARGET = 'target', // Receives the discount/benefit
BOTH = 'both', // Triggers and receives discount
GIFT = 'gift', // Free gift product
}
@Entity('promotion_products', { schema: 'retail' })
@Index(['tenantId', 'promotionId'])
@Index(['tenantId', 'productId'])
@Index(['tenantId', 'promotionId', 'productId'], { unique: true })
export class PromotionProduct {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ name: 'promotion_id', type: 'uuid' })
promotionId: string;
// Product (from erp-core)
@Column({ name: 'product_id', type: 'uuid' })
productId: string;
@Column({ name: 'product_code', length: 50 })
productCode: string;
@Column({ name: 'product_name', length: 200 })
productName: string;
// Variant (optional)
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
variantId: string;
@Column({
type: 'enum',
enum: ProductRole,
default: ProductRole.BOTH,
})
role: ProductRole;
// Specific discount for this product (overrides promotion default)
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
discountPercent: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
discountAmount: number;
@Column({ name: 'fixed_price', type: 'decimal', precision: 15, scale: 2, nullable: true })
fixedPrice: number;
// Quantity limits for this product
@Column({ name: 'min_quantity', type: 'int', default: 1 })
minQuantity: number;
@Column({ name: 'max_quantity', type: 'int', nullable: true })
maxQuantity: number;
// For bundles - quantity included in bundle
@Column({ name: 'bundle_quantity', type: 'int', default: 1 })
bundleQuantity: number;
// For buy X get Y - quantity to get free/discounted
@Column({ name: 'get_quantity', type: 'int', nullable: true })
getQuantity: number;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => Promotion, (promotion) => promotion.products)
@JoinColumn({ name: 'promotion_id' })
promotion: Promotion;
}

View File

@ -0,0 +1,224 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { PromotionProduct } from './promotion-product.entity';
export enum PromotionStatus {
DRAFT = 'draft',
SCHEDULED = 'scheduled',
ACTIVE = 'active',
PAUSED = 'paused',
ENDED = 'ended',
CANCELLED = 'cancelled',
}
export enum PromotionType {
PERCENTAGE_DISCOUNT = 'percentage_discount',
FIXED_DISCOUNT = 'fixed_discount',
BUY_X_GET_Y = 'buy_x_get_y',
BUNDLE = 'bundle',
FLASH_SALE = 'flash_sale',
QUANTITY_DISCOUNT = 'quantity_discount',
FREE_SHIPPING = 'free_shipping',
GIFT_WITH_PURCHASE = 'gift_with_purchase',
}
export enum DiscountApplication {
ORDER = 'order',
LINE = 'line',
SHIPPING = 'shipping',
}
@Entity('promotions', { schema: 'retail' })
@Index(['tenantId', 'status', 'startDate', 'endDate'])
@Index(['tenantId', 'code'], { unique: true })
@Index(['tenantId', 'type'])
export class Promotion {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId: string;
@Column({ length: 30, unique: true })
code: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({
type: 'enum',
enum: PromotionType,
})
type: PromotionType;
@Column({
type: 'enum',
enum: PromotionStatus,
default: PromotionStatus.DRAFT,
})
status: PromotionStatus;
@Column({
name: 'discount_application',
type: 'enum',
enum: DiscountApplication,
default: DiscountApplication.LINE,
})
discountApplication: DiscountApplication;
// Discount values
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
discountPercent: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
discountAmount: number;
@Column({ name: 'max_discount', type: 'decimal', precision: 15, scale: 2, nullable: true })
maxDiscount: number;
// Buy X Get Y config
@Column({ name: 'buy_quantity', type: 'int', nullable: true })
buyQuantity: number;
@Column({ name: 'get_quantity', type: 'int', nullable: true })
getQuantity: number;
@Column({ name: 'get_discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
getDiscountPercent: number;
// Quantity discount config
@Column({ name: 'quantity_tiers', type: 'jsonb', nullable: true })
quantityTiers: {
minQuantity: number;
discountPercent: number;
}[];
// Bundle config
@Column({ name: 'bundle_price', type: 'decimal', precision: 15, scale: 2, nullable: true })
bundlePrice: number;
// Validity
@Column({ name: 'start_date', type: 'timestamp with time zone' })
startDate: Date;
@Column({ name: 'end_date', type: 'timestamp with time zone', nullable: true })
endDate: Date;
// Time restrictions
@Column({ name: 'valid_days', type: 'jsonb', nullable: true })
validDays: string[]; // ['monday', 'tuesday', ...]
@Column({ name: 'valid_hours_start', type: 'time', nullable: true })
validHoursStart: string;
@Column({ name: 'valid_hours_end', type: 'time', nullable: true })
validHoursEnd: string;
// Usage limits
@Column({ name: 'max_uses_total', type: 'int', nullable: true })
maxUsesTotal: number;
@Column({ name: 'max_uses_per_customer', type: 'int', nullable: true })
maxUsesPerCustomer: number;
@Column({ name: 'current_uses', type: 'int', default: 0 })
currentUses: number;
// Minimum requirements
@Column({ name: 'min_order_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
minOrderAmount: number;
@Column({ name: 'min_quantity', type: 'int', nullable: true })
minQuantity: number;
// Scope
@Column({ name: 'applies_to_all_products', type: 'boolean', default: false })
appliesToAllProducts: boolean;
@Column({ name: 'included_categories', type: 'jsonb', nullable: true })
includedCategories: string[];
@Column({ name: 'excluded_categories', type: 'jsonb', nullable: true })
excludedCategories: string[];
@Column({ name: 'excluded_products', type: 'jsonb', nullable: true })
excludedProducts: string[];
@Column({ name: 'included_branches', type: 'jsonb', nullable: true })
includedBranches: string[];
// Customer restrictions
@Column({ name: 'customer_levels', type: 'jsonb', nullable: true })
customerLevels: string[]; // membership level IDs
@Column({ name: 'new_customers_only', type: 'boolean', default: false })
newCustomersOnly: boolean;
@Column({ name: 'loyalty_members_only', type: 'boolean', default: false })
loyaltyMembersOnly: boolean;
// Stackability
@Column({ name: 'stackable', type: 'boolean', default: false })
stackable: boolean;
@Column({ name: 'stackable_with', type: 'jsonb', nullable: true })
stackableWith: string[]; // promotion IDs that can stack
@Column({ type: 'int', default: 0 })
priority: number; // higher = applied first
// Display
@Column({ name: 'display_name', length: 100, nullable: true })
displayName: string;
@Column({ name: 'badge_text', length: 30, nullable: true })
badgeText: string;
@Column({ name: 'badge_color', length: 7, default: '#ff0000' })
badgeColor: string;
@Column({ name: 'image_url', length: 255, nullable: true })
imageUrl: string;
// Channels
@Column({ name: 'pos_enabled', type: 'boolean', default: true })
posEnabled: boolean;
@Column({ name: 'ecommerce_enabled', type: 'boolean', default: true })
ecommerceEnabled: boolean;
// Terms
@Column({ name: 'terms_and_conditions', type: 'text', nullable: true })
termsAndConditions: string;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<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[];
}

View File

@ -0,0 +1,14 @@
// Services
export * from './services';
// Controllers
export * from './controllers';
// Routes
export * from './routes';
// Validation
export * from './validation';
// Entities
export * from './entities';

View File

@ -0,0 +1 @@
export { default as pricingRoutes } from './pricing.routes';

View File

@ -0,0 +1,306 @@
import { Router } from 'express';
import { authMiddleware } from '../../auth/middleware/auth.middleware';
import { branchMiddleware } from '../../branches/middleware/branch.middleware';
import { validateRequest } from '../../../shared/middleware/validation.middleware';
import { requirePermissions } from '../../../shared/middleware/permissions.middleware';
import {
createPromotionSchema,
updatePromotionSchema,
addPromotionProductsSchema,
listPromotionsQuerySchema,
createCouponSchema,
updateCouponSchema,
generateBulkCouponsSchema,
redeemCouponSchema,
validateCouponSchema,
reverseRedemptionSchema,
listCouponsQuerySchema,
calculatePricesSchema,
} from '../validation/pricing.schema';
import * as pricingController from '../controllers/pricing.controller';
const router = Router();
// All routes require authentication
router.use(authMiddleware);
// ==================== PRICE ENGINE ROUTES ====================
/**
* Calculate prices for cart/order items
* Requires branch context
*/
router.post(
'/calculate',
branchMiddleware,
validateRequest(calculatePricesSchema),
requirePermissions('pricing:calculate'),
pricingController.calculatePrices
);
/**
* Validate a coupon code
* Requires branch context
*/
router.post(
'/validate-coupon',
branchMiddleware,
validateRequest(validateCouponSchema),
requirePermissions('pricing:validate'),
pricingController.validateCoupon
);
/**
* Get active promotions for current branch
* Requires branch context
*/
router.get(
'/active-promotions',
branchMiddleware,
requirePermissions('pricing:view'),
pricingController.getActivePromotions
);
// ==================== PROMOTION ROUTES ====================
/**
* List promotions with filters
*/
router.get(
'/promotions',
validateRequest(listPromotionsQuerySchema, 'query'),
requirePermissions('promotions:view'),
pricingController.listPromotions
);
/**
* Get promotion by ID
*/
router.get(
'/promotions/:id',
requirePermissions('promotions:view'),
pricingController.getPromotion
);
/**
* Create a new promotion
*/
router.post(
'/promotions',
validateRequest(createPromotionSchema),
requirePermissions('promotions:create'),
pricingController.createPromotion
);
/**
* Update a promotion
*/
router.put(
'/promotions/:id',
validateRequest(updatePromotionSchema),
requirePermissions('promotions:update'),
pricingController.updatePromotion
);
/**
* Activate a promotion
*/
router.post(
'/promotions/:id/activate',
requirePermissions('promotions:activate'),
pricingController.activatePromotion
);
/**
* Pause a promotion
*/
router.post(
'/promotions/:id/pause',
requirePermissions('promotions:update'),
pricingController.pausePromotion
);
/**
* End a promotion
*/
router.post(
'/promotions/:id/end',
requirePermissions('promotions:update'),
pricingController.endPromotion
);
/**
* Cancel a promotion
*/
router.post(
'/promotions/:id/cancel',
requirePermissions('promotions:delete'),
pricingController.cancelPromotion
);
/**
* Add products to a promotion
*/
router.post(
'/promotions/:id/products',
validateRequest(addPromotionProductsSchema),
requirePermissions('promotions:update'),
pricingController.addPromotionProducts
);
/**
* Remove product from a promotion
*/
router.delete(
'/promotions/:id/products/:productId',
requirePermissions('promotions:update'),
pricingController.removePromotionProduct
);
/**
* Delete a promotion (soft delete)
*/
router.delete(
'/promotions/:id',
requirePermissions('promotions:delete'),
pricingController.deletePromotion
);
// ==================== COUPON ROUTES ====================
/**
* List coupons with filters
*/
router.get(
'/coupons',
validateRequest(listCouponsQuerySchema, 'query'),
requirePermissions('coupons:view'),
pricingController.listCoupons
);
/**
* Get coupon by ID
*/
router.get(
'/coupons/:id',
requirePermissions('coupons:view'),
pricingController.getCoupon
);
/**
* Get coupon by code
*/
router.get(
'/coupons/code/:code',
requirePermissions('coupons:view'),
pricingController.getCouponByCode
);
/**
* Create a new coupon
*/
router.post(
'/coupons',
validateRequest(createCouponSchema),
requirePermissions('coupons:create'),
pricingController.createCoupon
);
/**
* Generate bulk coupons
*/
router.post(
'/coupons/bulk',
validateRequest(generateBulkCouponsSchema),
requirePermissions('coupons:create'),
pricingController.generateBulkCoupons
);
/**
* Update a coupon
*/
router.put(
'/coupons/:id',
validateRequest(updateCouponSchema),
requirePermissions('coupons:update'),
pricingController.updateCoupon
);
/**
* Activate a coupon
*/
router.post(
'/coupons/:id/activate',
requirePermissions('coupons:activate'),
pricingController.activateCoupon
);
/**
* Deactivate a coupon
*/
router.post(
'/coupons/:id/deactivate',
requirePermissions('coupons:update'),
pricingController.deactivateCoupon
);
/**
* Redeem a coupon by ID
*/
router.post(
'/coupons/:id/redeem',
branchMiddleware,
validateRequest(redeemCouponSchema),
requirePermissions('coupons:redeem'),
pricingController.redeemCoupon
);
/**
* Redeem coupon by code
*/
router.post(
'/coupons/redeem',
branchMiddleware,
validateRequest(redeemCouponSchema),
requirePermissions('coupons:redeem'),
pricingController.redeemCouponByCode
);
/**
* Reverse a coupon redemption
*/
router.post(
'/coupons/redemptions/:redemptionId/reverse',
validateRequest(reverseRedemptionSchema),
requirePermissions('coupons:reverse'),
pricingController.reverseRedemption
);
/**
* Get coupon redemption history
*/
router.get(
'/coupons/:id/redemptions',
requirePermissions('coupons:view'),
pricingController.getCouponRedemptions
);
/**
* Get coupon statistics
*/
router.get(
'/coupons/:id/stats',
requirePermissions('coupons:view'),
pricingController.getCouponStats
);
/**
* Delete a coupon (soft delete)
*/
router.delete(
'/coupons/:id',
requirePermissions('coupons:delete'),
pricingController.deleteCoupon
);
export default router;

View File

@ -0,0 +1,643 @@
import { Repository, DataSource, Between } from 'typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult, QueryOptions } from '../../../shared/types';
import { Coupon, CouponStatus, CouponType, CouponScope } from '../entities/coupon.entity';
import { CouponRedemption, RedemptionStatus } from '../entities/coupon-redemption.entity';
export interface CreateCouponInput {
code: string;
name: string;
description?: string;
type: CouponType;
scope?: CouponScope;
discountPercent?: number;
discountAmount?: number;
maxDiscount?: number;
freeProductId?: string;
freeProductQuantity?: number;
validFrom: Date;
validUntil?: Date;
maxUses?: number;
maxUsesPerCustomer?: number;
minOrderAmount?: number;
minItems?: number;
appliesToCategories?: string[];
appliesToProducts?: string[];
excludedProducts?: string[];
includedBranches?: string[];
customerSpecific?: boolean;
allowedCustomers?: string[];
customerLevels?: string[];
newCustomersOnly?: boolean;
firstOrderOnly?: boolean;
campaignId?: string;
campaignName?: string;
stackableWithPromotions?: boolean;
stackableWithCoupons?: boolean;
posEnabled?: boolean;
ecommerceEnabled?: boolean;
isSingleUse?: boolean;
autoApply?: boolean;
publicVisible?: boolean;
termsAndConditions?: string;
}
export interface RedeemCouponInput {
orderId: string;
orderNumber: string;
customerId?: string;
branchId: string;
orderAmount: number;
discountApplied: number;
channel: 'pos' | 'ecommerce' | 'mobile';
ipAddress?: string;
}
export interface CouponQueryOptions extends QueryOptions {
status?: CouponStatus;
type?: CouponType;
campaignId?: string;
branchId?: string;
activeOnly?: boolean;
publicOnly?: boolean;
}
export class CouponService extends BaseService<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;
}
}

View File

@ -0,0 +1,3 @@
export * from './price-engine.service';
export * from './promotion.service';
export * from './coupon.service';

View File

@ -0,0 +1,725 @@
import { Repository, LessThanOrEqual, MoreThanOrEqual, In } from 'typeorm';
import {
Promotion,
PromotionStatus,
PromotionType,
DiscountApplication,
} from '../entities/promotion.entity';
import { PromotionProduct, ProductRole } from '../entities/promotion-product.entity';
import { Coupon, CouponStatus, CouponType, CouponScope } from '../entities/coupon.entity';
export interface LineItem {
productId: string;
productCode: string;
productName: string;
variantId?: string;
categoryId?: string;
quantity: number;
unitPrice: number;
originalPrice: number;
}
export interface PriceContext {
tenantId: string;
branchId: string;
customerId?: string;
customerLevelId?: string;
isNewCustomer?: boolean;
isLoyaltyMember?: boolean;
isFirstOrder?: boolean;
channel: 'pos' | 'ecommerce' | 'mobile';
}
export interface AppliedDiscount {
type: 'promotion' | 'coupon';
id: string;
code: string;
name: string;
discountType: PromotionType | CouponType;
discountPercent?: number;
discountAmount?: number;
appliedToLineIndex?: number;
lineDiscount?: number;
orderDiscount?: number;
freeProductId?: string;
freeProductQuantity?: number;
}
export interface PricedLine extends LineItem {
lineIndex: number;
subtotal: number;
discountAmount: number;
discountPercent: number;
finalPrice: number;
appliedPromotions: string[];
}
export interface PricingResult {
lines: PricedLine[];
subtotal: number;
totalLineDiscounts: number;
orderDiscount: number;
totalDiscount: number;
total: number;
appliedDiscounts: AppliedDiscount[];
freeProducts: {
productId: string;
quantity: number;
sourcePromotion?: string;
sourceCoupon?: string;
}[];
errors: string[];
}
export class PriceEngineService {
constructor(
private readonly promotionRepository: Repository<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 };
}
}

View File

@ -0,0 +1,607 @@
import { Repository, DataSource, Between, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult, QueryOptions } from '../../../shared/types';
import {
Promotion,
PromotionStatus,
PromotionType,
DiscountApplication,
} from '../entities/promotion.entity';
import { PromotionProduct, ProductRole } from '../entities/promotion-product.entity';
export interface PromotionProductInput {
productId: string;
productCode: string;
productName: string;
variantId?: string;
role?: ProductRole;
discountPercent?: number;
discountAmount?: number;
fixedPrice?: number;
minQuantity?: number;
maxQuantity?: number;
bundleQuantity?: number;
getQuantity?: number;
}
export interface CreatePromotionInput {
code: string;
name: string;
description?: string;
type: PromotionType;
discountApplication?: DiscountApplication;
discountPercent?: number;
discountAmount?: number;
maxDiscount?: number;
buyQuantity?: number;
getQuantity?: number;
getDiscountPercent?: number;
quantityTiers?: { minQuantity: number; discountPercent: number }[];
bundlePrice?: number;
startDate: Date;
endDate?: Date;
validDays?: string[];
validHoursStart?: string;
validHoursEnd?: string;
maxUsesTotal?: number;
maxUsesPerCustomer?: number;
minOrderAmount?: number;
minQuantity?: number;
appliesToAllProducts?: boolean;
includedCategories?: string[];
excludedCategories?: string[];
excludedProducts?: string[];
includedBranches?: string[];
customerLevels?: string[];
newCustomersOnly?: boolean;
loyaltyMembersOnly?: boolean;
stackable?: boolean;
stackableWith?: string[];
priority?: number;
displayName?: string;
badgeText?: string;
badgeColor?: string;
imageUrl?: string;
posEnabled?: boolean;
ecommerceEnabled?: boolean;
termsAndConditions?: string;
products?: PromotionProductInput[];
}
export interface PromotionQueryOptions extends QueryOptions {
status?: PromotionStatus;
type?: PromotionType;
branchId?: string;
startDate?: Date;
endDate?: Date;
activeOnly?: boolean;
}
export class PromotionService extends BaseService<Promotion> {
constructor(
repository: Repository<Promotion>,
private readonly productRepository: Repository<PromotionProduct>,
private readonly dataSource: DataSource
) {
super(repository);
}
/**
* Create a new promotion with products
*/
async createPromotion(
tenantId: string,
input: CreatePromotionInput,
userId: string
): Promise<ServiceResult<Promotion>> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Check for duplicate code
const existing = await queryRunner.manager.findOne(Promotion, {
where: { tenantId, code: input.code.toUpperCase() },
});
if (existing) {
return {
success: false,
error: { code: 'DUPLICATE_CODE', message: 'Promotion code already exists' },
};
}
const promotion = queryRunner.manager.create(Promotion, {
tenantId,
code: input.code.toUpperCase(),
name: input.name,
description: input.description,
type: input.type,
status: PromotionStatus.DRAFT,
discountApplication: input.discountApplication ?? DiscountApplication.LINE,
discountPercent: input.discountPercent,
discountAmount: input.discountAmount,
maxDiscount: input.maxDiscount,
buyQuantity: input.buyQuantity,
getQuantity: input.getQuantity,
getDiscountPercent: input.getDiscountPercent,
quantityTiers: input.quantityTiers,
bundlePrice: input.bundlePrice,
startDate: input.startDate,
endDate: input.endDate,
validDays: input.validDays,
validHoursStart: input.validHoursStart,
validHoursEnd: input.validHoursEnd,
maxUsesTotal: input.maxUsesTotal,
maxUsesPerCustomer: input.maxUsesPerCustomer,
minOrderAmount: input.minOrderAmount,
minQuantity: input.minQuantity,
appliesToAllProducts: input.appliesToAllProducts ?? false,
includedCategories: input.includedCategories,
excludedCategories: input.excludedCategories,
excludedProducts: input.excludedProducts,
includedBranches: input.includedBranches,
customerLevels: input.customerLevels,
newCustomersOnly: input.newCustomersOnly ?? false,
loyaltyMembersOnly: input.loyaltyMembersOnly ?? false,
stackable: input.stackable ?? false,
stackableWith: input.stackableWith,
priority: input.priority ?? 0,
displayName: input.displayName,
badgeText: input.badgeText,
badgeColor: input.badgeColor ?? '#ff0000',
imageUrl: input.imageUrl,
posEnabled: input.posEnabled ?? true,
ecommerceEnabled: input.ecommerceEnabled ?? true,
termsAndConditions: input.termsAndConditions,
createdBy: userId,
});
const savedPromotion = await queryRunner.manager.save(promotion);
// Create promotion products
if (input.products && input.products.length > 0) {
for (const productInput of input.products) {
const promotionProduct = queryRunner.manager.create(PromotionProduct, {
tenantId,
promotionId: savedPromotion.id,
productId: productInput.productId,
productCode: productInput.productCode,
productName: productInput.productName,
variantId: productInput.variantId,
role: productInput.role ?? ProductRole.BOTH,
discountPercent: productInput.discountPercent,
discountAmount: productInput.discountAmount,
fixedPrice: productInput.fixedPrice,
minQuantity: productInput.minQuantity ?? 1,
maxQuantity: productInput.maxQuantity,
bundleQuantity: productInput.bundleQuantity ?? 1,
getQuantity: productInput.getQuantity,
});
await queryRunner.manager.save(promotionProduct);
}
}
await queryRunner.commitTransaction();
// Reload with products
const result = await this.repository.findOne({
where: { id: savedPromotion.id, tenantId },
relations: ['products'],
});
return { success: true, data: result! };
} catch (error) {
await queryRunner.rollbackTransaction();
return {
success: false,
error: {
code: 'CREATE_PROMOTION_ERROR',
message: error instanceof Error ? error.message : 'Failed to create promotion',
},
};
} finally {
await queryRunner.release();
}
}
/**
* Update promotion
*/
async updatePromotion(
tenantId: string,
id: string,
input: Partial<CreatePromotionInput>,
userId: string
): Promise<ServiceResult<Promotion>> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
// Don't allow editing active promotions (only some fields)
if (promotion.status === PromotionStatus.ACTIVE) {
const allowedFields = ['endDate', 'maxUsesTotal', 'priority', 'status'];
const inputKeys = Object.keys(input);
const hasDisallowedFields = inputKeys.some(k => !allowedFields.includes(k));
if (hasDisallowedFields) {
return {
success: false,
error: {
code: 'PROMOTION_ACTIVE',
message: 'Cannot modify active promotion. Only endDate, maxUsesTotal, priority, and status can be changed.',
},
};
}
}
// Update allowed fields
const { products, ...updateData } = input;
Object.assign(promotion, updateData, { updatedBy: userId });
const saved = await this.repository.save(promotion);
return { success: true, data: saved };
}
/**
* Activate promotion
*/
async activatePromotion(
tenantId: string,
id: string,
userId: string
): Promise<ServiceResult<Promotion>> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
relations: ['products'],
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if (promotion.status !== PromotionStatus.DRAFT && promotion.status !== PromotionStatus.SCHEDULED) {
return {
success: false,
error: { code: 'INVALID_STATUS', message: 'Can only activate draft or scheduled promotions' },
};
}
// Validate promotion has required data
if (!promotion.appliesToAllProducts && (!promotion.products || promotion.products.length === 0)) {
if (!promotion.includedCategories || promotion.includedCategories.length === 0) {
return {
success: false,
error: { code: 'NO_PRODUCTS', message: 'Promotion must have products or categories defined' },
};
}
}
const now = new Date();
if (promotion.startDate > now) {
promotion.status = PromotionStatus.SCHEDULED;
} else {
promotion.status = PromotionStatus.ACTIVE;
}
promotion.updatedBy = userId;
const saved = await this.repository.save(promotion);
return { success: true, data: saved };
}
/**
* Pause promotion
*/
async pausePromotion(
tenantId: string,
id: string,
userId: string
): Promise<ServiceResult<Promotion>> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if (promotion.status !== PromotionStatus.ACTIVE) {
return {
success: false,
error: { code: 'INVALID_STATUS', message: 'Can only pause active promotions' },
};
}
promotion.status = PromotionStatus.PAUSED;
promotion.updatedBy = userId;
const saved = await this.repository.save(promotion);
return { success: true, data: saved };
}
/**
* End promotion
*/
async endPromotion(
tenantId: string,
id: string,
userId: string
): Promise<ServiceResult<Promotion>> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if ([PromotionStatus.ENDED, PromotionStatus.CANCELLED].includes(promotion.status)) {
return {
success: false,
error: { code: 'INVALID_STATUS', message: 'Promotion is already ended or cancelled' },
};
}
promotion.status = PromotionStatus.ENDED;
promotion.endDate = new Date();
promotion.updatedBy = userId;
const saved = await this.repository.save(promotion);
return { success: true, data: saved };
}
/**
* Cancel promotion
*/
async cancelPromotion(
tenantId: string,
id: string,
userId: string
): Promise<ServiceResult<Promotion>> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if (promotion.status === PromotionStatus.CANCELLED) {
return {
success: false,
error: { code: 'ALREADY_CANCELLED', message: 'Promotion is already cancelled' },
};
}
promotion.status = PromotionStatus.CANCELLED;
promotion.updatedBy = userId;
const saved = await this.repository.save(promotion);
return { success: true, data: saved };
}
/**
* Add products to promotion
*/
async addProducts(
tenantId: string,
promotionId: string,
products: PromotionProductInput[],
userId: string
): Promise<ServiceResult<PromotionProduct[]>> {
const promotion = await this.repository.findOne({
where: { id: promotionId, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if (promotion.status === PromotionStatus.ACTIVE) {
return {
success: false,
error: { code: 'PROMOTION_ACTIVE', message: 'Cannot add products to active promotion' },
};
}
const created: PromotionProduct[] = [];
for (const productInput of products) {
// Check if already exists
const existing = await this.productRepository.findOne({
where: {
tenantId,
promotionId,
productId: productInput.productId,
variantId: productInput.variantId ?? undefined,
},
});
if (existing) continue;
const promotionProduct = this.productRepository.create({
tenantId,
promotionId,
productId: productInput.productId,
productCode: productInput.productCode,
productName: productInput.productName,
variantId: productInput.variantId,
role: productInput.role ?? ProductRole.BOTH,
discountPercent: productInput.discountPercent,
discountAmount: productInput.discountAmount,
fixedPrice: productInput.fixedPrice,
minQuantity: productInput.minQuantity ?? 1,
maxQuantity: productInput.maxQuantity,
bundleQuantity: productInput.bundleQuantity ?? 1,
getQuantity: productInput.getQuantity,
});
const saved = await this.productRepository.save(promotionProduct);
created.push(saved);
}
return { success: true, data: created };
}
/**
* Remove product from promotion
*/
async removeProduct(
tenantId: string,
promotionId: string,
productId: string
): Promise<ServiceResult<boolean>> {
const promotion = await this.repository.findOne({
where: { id: promotionId, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if (promotion.status === PromotionStatus.ACTIVE) {
return {
success: false,
error: { code: 'PROMOTION_ACTIVE', message: 'Cannot remove products from active promotion' },
};
}
await this.productRepository.delete({
tenantId,
promotionId,
productId,
});
return { success: true, data: true };
}
/**
* Find promotions with filters
*/
async findPromotions(
tenantId: string,
options: PromotionQueryOptions
): Promise<{ data: Promotion[]; total: number }> {
const qb = this.repository.createQueryBuilder('promotion')
.leftJoinAndSelect('promotion.products', 'products')
.where('promotion.tenantId = :tenantId', { tenantId });
if (options.status) {
qb.andWhere('promotion.status = :status', { status: options.status });
}
if (options.type) {
qb.andWhere('promotion.type = :type', { type: options.type });
}
if (options.branchId) {
qb.andWhere('(promotion.includedBranches IS NULL OR promotion.includedBranches @> :branchArray)', {
branchArray: JSON.stringify([options.branchId]),
});
}
if (options.activeOnly) {
const now = new Date();
qb.andWhere('promotion.status = :activeStatus', { activeStatus: PromotionStatus.ACTIVE })
.andWhere('promotion.startDate <= :now', { now })
.andWhere('(promotion.endDate IS NULL OR promotion.endDate >= :now)', { now });
}
if (options.startDate && options.endDate) {
qb.andWhere('promotion.startDate BETWEEN :startDate AND :endDate', {
startDate: options.startDate,
endDate: options.endDate,
});
}
const page = options.page ?? 1;
const limit = options.limit ?? 20;
qb.skip((page - 1) * limit).take(limit);
qb.orderBy('promotion.priority', 'DESC')
.addOrderBy('promotion.createdAt', 'DESC');
const [data, total] = await qb.getManyAndCount();
return { data, total };
}
/**
* Get promotion with products
*/
async getPromotionWithProducts(
tenantId: string,
id: string
): Promise<Promotion | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['products'],
});
}
/**
* Increment usage count
*/
async incrementUsage(
tenantId: string,
id: string
): Promise<void> {
await this.repository.increment(
{ id, tenantId },
'currentUses',
1
);
}
/**
* Get promotion statistics
*/
async getPromotionStats(
tenantId: string,
id: string
): Promise<{
totalUses: number;
totalDiscount: number;
averageDiscount: number;
}> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
});
if (!promotion) {
return { totalUses: 0, totalDiscount: 0, averageDiscount: 0 };
}
// Would need to query order data for actual discount amounts
// For now, return basic usage stats
return {
totalUses: promotion.currentUses,
totalDiscount: 0, // Would calculate from orders
averageDiscount: 0,
};
}
}

View File

@ -0,0 +1 @@
export * from './pricing.schema';

Some files were not shown because too many files have changed in this diff Show More