diff --git a/.gitignore b/.gitignore index d9fb62f..e8ab42c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ coverage/ # Coolify / Docker local overrides docker-compose.override.yml +.tasker diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3279d3b..0bffed2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -17,6 +17,7 @@ import { ProductsModule } from "./products/products.module"; import { WarehousesModule } from "./warehouses/warehouses.module"; import { IntegrationsModule } from "./integrations/integrations.module"; import { PaymentsModule } from "./payments/payments.module"; +import { ConsolidationsModule } from "./consolidations/consolidations.module"; @Module({ imports: [ @@ -41,6 +42,7 @@ import { PaymentsModule } from "./payments/payments.module"; WarehousesModule, IntegrationsModule, PaymentsModule, + ConsolidationsModule, ], }) export class AppModule {} diff --git a/apps/api/src/consolidations/consolidations.controller.ts b/apps/api/src/consolidations/consolidations.controller.ts new file mode 100644 index 0000000..facdfc4 --- /dev/null +++ b/apps/api/src/consolidations/consolidations.controller.ts @@ -0,0 +1,64 @@ +import { + Controller, Get, Post, Delete, Body, Param, Request, + UseGuards, Query, +} from "@nestjs/common"; +import { JwtAuthGuard } from "../auth/guards/auth.guard"; +import { ConsolidationsService } from "./consolidations.service"; + +class CreateConsolidationDto { notes?: string; } +class AddPackageDto { packageId!: string; } +class CloseDto { courierTracking?: string; } +class DispatchDto { courierTracking!: string; } + +@Controller("consolidations") +@UseGuards(JwtAuthGuard) +export class ConsolidationsController { + constructor(private readonly svc: ConsolidationsService) {} + + /** GET /consolidations — lista. Cliente ve solo las suyas; operador/admin ve todas */ + @Get() + list(@Request() req: any): Promise { + const isClient = req.user.role === "CLIENTE"; + return this.svc.list(req.user.tenantId, isClient ? req.user.id : undefined); + } + + /** GET /consolidations/:id */ + @Get(":id") + findOne(@Param("id") id: string, @Request() req: any): Promise { + return this.svc.findOne(id, req.user.tenantId); + } + + /** POST /consolidations — el cliente crea una consolidación */ + @Post() + create(@Body() dto: CreateConsolidationDto, @Request() req: any): Promise { + return this.svc.create(req.user.tenantId, req.user.id, dto.notes); + } + + /** POST /consolidations/:id/packages — agrega paquete */ + @Post(":id/packages") + addPackage(@Param("id") id: string, @Body() dto: AddPackageDto, @Request() req: any): Promise { + return this.svc.addPackage(id, dto.packageId, req.user.tenantId); + } + + /** DELETE /consolidations/:id/packages/:packageId — quita paquete */ + @Delete(":id/packages/:packageId") + removePackage( + @Param("id") id: string, + @Param("packageId") packageId: string, + @Request() req: any, + ): Promise { + return this.svc.removePackage(id, packageId, req.user.tenantId); + } + + /** POST /consolidations/:id/close — cierra la consolidación */ + @Post(":id/close") + close(@Param("id") id: string, @Body() dto: CloseDto, @Request() req: any): Promise { + return this.svc.close(id, req.user.tenantId, dto.courierTracking); + } + + /** POST /consolidations/:id/dispatch — despacha y mueve paquetes a EN_TRANSITO_ECUADOR */ + @Post(":id/dispatch") + dispatch(@Param("id") id: string, @Body() dto: DispatchDto, @Request() req: any): Promise { + return this.svc.dispatch(id, req.user.tenantId, dto.courierTracking); + } +} diff --git a/apps/api/src/consolidations/consolidations.module.ts b/apps/api/src/consolidations/consolidations.module.ts new file mode 100644 index 0000000..47239f8 --- /dev/null +++ b/apps/api/src/consolidations/consolidations.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { ConsolidationsService } from "./consolidations.service"; +import { ConsolidationsController } from "./consolidations.controller"; +import { PrismaModule } from "../prisma/prisma.module"; + +@Module({ + imports: [PrismaModule], + controllers: [ConsolidationsController], + providers: [ConsolidationsService], + exports: [ConsolidationsService], +}) +export class ConsolidationsModule {} diff --git a/apps/api/src/consolidations/consolidations.service.ts b/apps/api/src/consolidations/consolidations.service.ts new file mode 100644 index 0000000..d9915c3 --- /dev/null +++ b/apps/api/src/consolidations/consolidations.service.ts @@ -0,0 +1,162 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; + +function genCode(): string { + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const rand = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `CON-${date}-${rand}`; +} + +@Injectable() +export class ConsolidationsService { + private readonly logger = new Logger(ConsolidationsService.name); + + constructor(private prisma: PrismaService) {} + + /** Lista consolidaciones del tenant. Cliente solo ve las suyas. */ + async list(tenantId: string, userId?: string): Promise { + return this.prisma.client.consolidation.findMany({ + where: { tenantId, ...(userId ? { userId } : {}) }, + include: { + packages: { + include: { + package: { select: { id: true, trackingId: true, description: true, declaredValue: true, actualWeight: true, status: true } }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + } + + /** Obtiene una consolidación por ID */ + async findOne(id: string, tenantId: string): Promise { + const c = await this.prisma.client.consolidation.findFirst({ + where: { id, tenantId }, + include: { + packages: { + include: { + package: { select: { id: true, trackingId: true, description: true, declaredValue: true, actualWeight: true, status: true } }, + }, + }, + }, + }); + if (!c) throw new NotFoundException("Consolidación no encontrada"); + return c; + } + + /** Crea una consolidación vacía para un cliente */ + async create(tenantId: string, userId: string, notes?: string): Promise { + return this.prisma.client.consolidation.create({ + data: { tenantId, userId, code: genCode(), notes, status: "ABIERTA" }, + }); + } + + /** Agrega un paquete a la consolidación (§21 — solo paquetes VERIFICADOS) */ + async addPackage(id: string, packageId: string, tenantId: string): Promise { + const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId } }); + if (!c) throw new NotFoundException("Consolidación no encontrada"); + if (c.status !== "ABIERTA") throw new BadRequestException("Solo se pueden agregar paquetes a consolidaciones ABIERTAS"); + + const pkg = await this.prisma.client.package.findFirst({ where: { id: packageId, tenantId } }); + if (!pkg) throw new NotFoundException("Paquete no encontrado"); + if (!["VERIFICADO", "RECIBIDO_BODEGA"].includes(pkg.status)) { + throw new BadRequestException(`El paquete debe estar en estado VERIFICADO o RECIBIDO_BODEGA. Estado actual: ${pkg.status}`); + } + + // Verificar que no esté ya en otra consolidación + const existing = await this.prisma.client.consolidationPackage.findUnique({ where: { packageId } }); + if (existing) throw new BadRequestException("El paquete ya está en una consolidación"); + + await this.prisma.client.consolidationPackage.create({ + data: { consolidationId: id, packageId }, + }); + + return this.recalcTotals(id, tenantId); + } + + /** Quita un paquete de la consolidación */ + async removePackage(id: string, packageId: string, tenantId: string): Promise { + const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId } }); + if (!c) throw new NotFoundException("Consolidación no encontrada"); + if (c.status !== "ABIERTA") throw new BadRequestException("No se pueden quitar paquetes de una consolidación cerrada"); + + await this.prisma.client.consolidationPackage.deleteMany({ + where: { consolidationId: id, packageId }, + }); + + return this.recalcTotals(id, tenantId); + } + + /** Cierra la consolidación y la marca lista para despacho */ + async close(id: string, tenantId: string, courierTracking?: string): Promise { + const c = await this.prisma.client.consolidation.findFirst({ + where: { id, tenantId }, + include: { packages: true }, + }); + if (!c) throw new NotFoundException("Consolidación no encontrada"); + if (c.status !== "ABIERTA") throw new BadRequestException("La consolidación ya está cerrada"); + if (c.packages.length === 0) throw new BadRequestException("No se puede cerrar una consolidación vacía"); + + return this.prisma.client.consolidation.update({ + where: { id }, + data: { status: "CERRADA", courierTracking: courierTracking ?? null }, + }); + } + + /** Marca como despachada (courier recogió el paquete) */ + async dispatch(id: string, tenantId: string, courierTracking: string): Promise { + const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId } }); + if (!c) throw new NotFoundException("Consolidación no encontrada"); + if (c.status !== "CERRADA") throw new BadRequestException("La consolidación debe estar CERRADA para despachar"); + + // Actualizar todos los paquetes a EN_TRANSITO_ECUADOR + const pkgIds = await this.prisma.client.consolidationPackage.findMany({ + where: { consolidationId: id }, + select: { packageId: true }, + }); + + await this.prisma.client.package.updateMany({ + where: { id: { in: pkgIds.map(p => p.packageId) } }, + data: { status: "EN_TRANSITO_ECUADOR" }, + }); + + this.logger.log(`[CONSOLIDATION] Dispatched ${id} — ${pkgIds.length} packages → EN_TRANSITO_ECUADOR`); + + return this.prisma.client.consolidation.update({ + where: { id }, + data: { status: "DESPACHADA", courierTracking }, + }); + } + + /** Recalcula totales de peso y valor */ + private async recalcTotals(id: string, _tenantId: string): Promise { + const cp = await this.prisma.client.consolidationPackage.findMany({ + where: { consolidationId: id }, + include: { package: { select: { declaredValue: true, actualWeight: true, declaredWeight: true } } }, + }); + + const totalWeightLb = cp.reduce((s, cp) => + s + Number(cp.package.actualWeight ?? cp.package.declaredWeight ?? 0), 0); + const totalValue = cp.reduce((s, cp) => s + Number(cp.package.declaredValue ?? 0), 0); + + return this.prisma.client.consolidation.update({ + where: { id }, + data: { + totalWeightLb: Math.round(totalWeightLb * 100) / 100, + totalValue: Math.round(totalValue * 100) / 100, + }, + include: { + packages: { + include: { + package: { select: { id: true, trackingId: true, description: true, declaredValue: true, actualWeight: true, status: true } }, + }, + }, + }, + }); + } +} diff --git a/apps/api/src/notifications/notifications.service.spec.ts b/apps/api/src/notifications/notifications.service.spec.ts new file mode 100644 index 0000000..e5f9485 --- /dev/null +++ b/apps/api/src/notifications/notifications.service.spec.ts @@ -0,0 +1,168 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { NotFoundException } from "@nestjs/common"; +import { NotificationsService } from "./notifications.service"; +import { PrismaService } from "../prisma/prisma.service"; + +const mockTemplate = { + id: "tpl-1", + tenantId: "tenant-1", + event: "REGISTRADO", + channel: "EMAIL", + subject: "Tu paquete {{trackingId}} fue registrado", + body: "Hola {{firstName}}, tu paquete {{trackingId}} fue registrado.", + isActive: true, + updatedAt: new Date(), +}; + +const mockPackage = { + id: "pkg-1", + tenantId: "tenant-1", + userId: "user-1", + trackingId: "EC-20260601-ABCDEF", + status: "REGISTRADO", +}; + +const mockUser = { id: "user-1", firstName: "Juan", lastName: "Pérez", suite: { code: "EC-00001" } }; + +const mockPrisma = { + client: { + notificationTemplate: { + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + upsert: jest.fn(), + }, + notification: { + create: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + findMany: jest.fn(), + }, + }, +}; + +describe("NotificationsService", () => { + let service: NotificationsService; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationsService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + service = module.get(NotificationsService); + }); + + // ─── getTemplates ──────────────────────────────────────────── + + describe("getTemplates", () => { + it("devuelve plantillas del tenant", async () => { + mockPrisma.client.notificationTemplate.findMany.mockResolvedValue([mockTemplate]); + const result = await service.getTemplates("tenant-1"); + expect(result).toHaveLength(1); + expect(mockPrisma.client.notificationTemplate.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { tenantId: "tenant-1" } }) + ); + }); + }); + + // ─── updateTemplate ────────────────────────────────────────── + + describe("updateTemplate", () => { + it("actualiza una plantilla correctamente", async () => { + mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue(mockTemplate); + mockPrisma.client.notificationTemplate.update.mockResolvedValue({ ...mockTemplate, body: "Nuevo cuerpo" }); + const result = await service.updateTemplate("tpl-1", "tenant-1", "Nuevo cuerpo", undefined, true); + expect(mockPrisma.client.notificationTemplate.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "tpl-1" } }) + ); + }); + + it("lanza NotFoundException si la plantilla no existe", async () => { + mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue(null); + await expect(service.updateTemplate("invalid", "tenant-1", "body")) + .rejects.toThrow(NotFoundException); + }); + + it("lanza NotFoundException si el tenantId no coincide", async () => { + mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({ ...mockTemplate, tenantId: "otro-tenant" }); + await expect(service.updateTemplate("tpl-1", "tenant-1", "body")) + .rejects.toThrow(NotFoundException); + }); + }); + + // ─── seedDefaultTemplates ─────────────────────────────────── + + describe("seedDefaultTemplates", () => { + it("crea upserts para 11 eventos × 3 canales = 33 plantillas", async () => { + mockPrisma.client.notificationTemplate.upsert.mockResolvedValue(mockTemplate); + const result = await service.seedDefaultTemplates("tenant-1"); + expect(result.seeded).toBe(33); + expect(mockPrisma.client.notificationTemplate.upsert).toHaveBeenCalledTimes(33); + }); + + it("es idempotente — usa upsert con update vacío", async () => { + mockPrisma.client.notificationTemplate.upsert.mockResolvedValue(mockTemplate); + await service.seedDefaultTemplates("tenant-1"); + const call = mockPrisma.client.notificationTemplate.upsert.mock.calls[0][0]; + expect(call.update).toEqual({}); + }); + }); + + // ─── notifyStatusChange ────────────────────────────────────── + + describe("notifyStatusChange", () => { + beforeEach(() => { + mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue(null); // usa fallback + mockPrisma.client.notification.create.mockResolvedValue({ id: "notif-1" }); + mockPrisma.client.notification.update.mockResolvedValue({}); + }); + + it("crea notificaciones para los 3 canales por defecto", async () => { + await service.notifyStatusChange(mockPackage, mockUser); + expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3); // EMAIL, WHATSAPP, PUSH + }); + + it("interpola {{trackingId}} y {{firstName}} en el body", async () => { + mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({ + ...mockTemplate, + body: "Hola {{firstName}}, tu paquete es {{trackingId}}.", + }); + await service.notifyStatusChange(mockPackage, mockUser); + const createCall = mockPrisma.client.notification.create.mock.calls[0][0]; + expect(createCall.data.body).toContain("Juan"); + expect(createCall.data.body).toContain("EC-20260601-ABCDEF"); + }); + + it("no envía notificación si la plantilla está inactiva", async () => { + mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({ ...mockTemplate, isActive: false }); + await service.notifyStatusChange(mockPackage, mockUser); + // Solo EMAIL está deshabilitado, WHATSAPP y PUSH usan fallback (activo) + // El mock devuelve el template inactivo para todos + expect(mockPrisma.client.notification.create).not.toHaveBeenCalled(); + }); + + it("no lanza excepción si la creación de notificación falla", async () => { + mockPrisma.client.notification.create.mockRejectedValue(new Error("DB error")); + await expect(service.notifyStatusChange(mockPackage, mockUser)).resolves.not.toThrow(); + }); + }); + + // ─── findByUser ────────────────────────────────────────────── + + describe("findByUser", () => { + it("devuelve notificaciones del usuario ordenadas por fecha", async () => { + mockPrisma.client.notification.findMany.mockResolvedValue([]); + await service.findByUser("user-1"); + expect(mockPrisma.client.notification.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1" }, + orderBy: { createdAt: "desc" }, + take: 20, + }) + ); + }); + }); +}); diff --git a/apps/api/src/payments/payments.service.spec.ts b/apps/api/src/payments/payments.service.spec.ts new file mode 100644 index 0000000..04fd3f7 --- /dev/null +++ b/apps/api/src/payments/payments.service.spec.ts @@ -0,0 +1,203 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { NotFoundException, BadRequestException } from "@nestjs/common"; +import { PaymentsService } from "./payments.service"; +import { PrismaService } from "../prisma/prisma.service"; + +// Mock del PrismaService +const mockPayment = { + id: "pay-1", + tenantId: "tenant-1", + packageId: "pkg-1", + userId: "user-1", + amount: 35.0, + currency: "USD", + provider: "stripe", + providerRef: "pi_stub_123", + status: "PENDIENTE", + paidAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockPackage = { + id: "pkg-1", + tenantId: "tenant-1", + userId: "user-1", + trackingId: "EC-20260601-ABCDEF", + status: "VERIFICADO", + declaredValue: "100.00", + actualWeight: "3.5", + declaredWeight: "3.0", + description: "Smartphone", + paidAt: null, + payment: null, +}; + +const mockTariff = { + pricePerLb: "3.50", + insurancePct: "0.02", + fodinfaPct: "0.005", +}; + +const mockPrisma = { + client: { + payment: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + package: { + findFirst: jest.fn(), + update: jest.fn(), + }, + tariff: { + findUnique: jest.fn(), + }, + $transaction: jest.fn(), + }, +}; + +describe("PaymentsService", () => { + let service: PaymentsService; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PaymentsService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + service = module.get(PaymentsService); + }); + + // ─── createIntent ─────────────────────────────────────────── + + describe("createIntent", () => { + beforeEach(() => { + mockPrisma.client.package.findFirst.mockResolvedValue(mockPackage); + mockPrisma.client.tariff.findUnique.mockResolvedValue(mockTariff); + mockPrisma.client.payment.findUnique.mockResolvedValue(null); + mockPrisma.client.payment.create.mockResolvedValue(mockPayment); + }); + + it("crea un PaymentIntent correctamente", async () => { + const result = await service.createIntent("pkg-1", "user-1", "tenant-1"); + expect(mockPrisma.client.payment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + packageId: "pkg-1", + userId: "user-1", + tenantId: "tenant-1", + status: "PENDIENTE", + provider: "stripe", + }), + }) + ); + expect(result.status).toBe("PENDIENTE"); + }); + + it("lanza NotFoundException si el paquete no existe", async () => { + mockPrisma.client.package.findFirst.mockResolvedValue(null); + await expect(service.createIntent("invalid", "user-1", "tenant-1")) + .rejects.toThrow(NotFoundException); + }); + + it("lanza BadRequestException si el paquete ya fue pagado", async () => { + mockPrisma.client.package.findFirst.mockResolvedValue({ ...mockPackage, paidAt: new Date() }); + await expect(service.createIntent("pkg-1", "user-1", "tenant-1")) + .rejects.toThrow(BadRequestException); + }); + + it("reutiliza un intent PENDIENTE existente", async () => { + mockPrisma.client.payment.findUnique.mockResolvedValue(mockPayment); + const result = await service.createIntent("pkg-1", "user-1", "tenant-1"); + expect(mockPrisma.client.payment.create).not.toHaveBeenCalled(); + expect(result).toEqual(mockPayment); + }); + + it("calcula el monto correctamente (flete + seguro)", async () => { + // peso 3.5lb × $3.50 = $12.25 flete + $100 × 2% = $2 seguro = $14.25 + mockPrisma.client.payment.create.mockImplementation(({ data }: any) => + Promise.resolve({ ...mockPayment, amount: data.amount }) + ); + const result = await service.createIntent("pkg-1", "user-1", "tenant-1"); + expect(Number(result.amount)).toBeCloseTo(14.25, 1); + }); + }); + + // ─── confirm ───────────────────────────────────────────────── + + describe("confirm", () => { + it("confirma el pago y marca el paquete como pagado", async () => { + mockPrisma.client.payment.findUnique.mockResolvedValue(mockPayment); + const confirmed = { ...mockPayment, status: "COMPLETADO", paidAt: new Date() }; + mockPrisma.client.$transaction.mockResolvedValue([confirmed, {}]); + + const result = await service.confirm("pay-1", "tenant-1"); + expect(mockPrisma.client.$transaction).toHaveBeenCalled(); + }); + + it("lanza NotFoundException si el pago no existe", async () => { + mockPrisma.client.payment.findUnique.mockResolvedValue(null); + await expect(service.confirm("invalid", "tenant-1")) + .rejects.toThrow(NotFoundException); + }); + + it("lanza BadRequestException si el pago ya está completado", async () => { + mockPrisma.client.payment.findUnique.mockResolvedValue({ ...mockPayment, status: "COMPLETADO" }); + await expect(service.confirm("pay-1", "tenant-1")) + .rejects.toThrow(BadRequestException); + }); + + it("lanza NotFoundException si el tenantId no coincide", async () => { + mockPrisma.client.payment.findUnique.mockResolvedValue({ ...mockPayment, tenantId: "other-tenant" }); + await expect(service.confirm("pay-1", "tenant-1")) + .rejects.toThrow(NotFoundException); + }); + }); + + // ─── list ───────────────────────────────────────────────────── + + describe("list", () => { + it("lista pagos del tenant", async () => { + mockPrisma.client.payment.findMany.mockResolvedValue([mockPayment]); + const result = await service.list("tenant-1"); + expect(result).toHaveLength(1); + expect(mockPrisma.client.payment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ tenantId: "tenant-1" }) }) + ); + }); + + it("filtra por status si se provee", async () => { + mockPrisma.client.payment.findMany.mockResolvedValue([]); + await service.list("tenant-1", "COMPLETADO"); + expect(mockPrisma.client.payment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: "COMPLETADO" }), + }) + ); + }); + }); + + // ─── findByPackageForUser ───────────────────────────────────── + + describe("findByPackageForUser", () => { + it("devuelve desglose de costos correcto", async () => { + mockPrisma.client.package.findFirst.mockResolvedValue({ ...mockPackage, payment: mockPayment }); + mockPrisma.client.tariff.findUnique.mockResolvedValue(mockTariff); + const result = await service.findByPackageForUser("pkg-1", "user-1", "tenant-1"); + expect(result.breakdown).toBeDefined(); + expect(result.breakdown.freight).toBeCloseTo(12.25, 1); + expect(result.breakdown.insurance).toBeCloseTo(2.0, 1); + expect(result.breakdown.total).toBeGreaterThan(0); + }); + + it("lanza NotFoundException si el paquete no pertenece al usuario", async () => { + mockPrisma.client.package.findFirst.mockResolvedValue(null); + await expect(service.findByPackageForUser("pkg-1", "other-user", "tenant-1")) + .rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index 16aeb8b..23c9462 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -9,6 +9,7 @@ const NAV = [ { href: "/admin", icon: "◈", label: "Dashboard" }, { href: "/admin/usuarios", icon: "👥", label: "Usuarios" }, { href: "/admin/tarifas", icon: "💰", label: "Tarifas" }, + { href: "/admin/pagos", icon: "💳", label: "Cobros" }, { href: "/admin/notificaciones", icon: "📋", label: "Notificaciones" }, { href: "/admin/configuracion", icon: "⚙️", label: "Configuración" }, { href: "/admin/reportes", icon: "📊", label: "Reportes" }, diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index f02543d..224e601 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -3,72 +3,199 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { api } from "@/lib/api"; +// §12 Admin Dashboard — métricas reales de usuarios, paquetes, pagos y B2B + export default function AdminDashboard() { - const [users, setUsers] = useState([]); - const [b2b, setB2b] = useState([]); - const [loading, setLoading] = useState(true); + const [users, setUsers] = useState([]); + const [packages, setPackages] = useState([]); + const [b2b, setB2b] = useState([]); + const [payments, setPayments] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { - Promise.all([api.users.list(), api.b2b.list()]) - .then(([u, b]) => { setUsers(u); setB2b(b); }) - .catch(() => {}).finally(() => setLoading(false)); + Promise.all([ + api.users.list(), + api.packages.list(), + api.b2b.list(), + api.payments.list(), + ]) + .then(([u, p, b, pay]) => { setUsers(u); setPackages(p); setB2b(b); setPayments(pay); }) + .catch(() => {}) + .finally(() => setLoading(false)); }, []); - if (loading) return
; + if (loading) return ( +
+
+
+ ); - const clientes = users.filter(u => u.role === "CLIENTE").length; - const b2bPending = b2b.filter(r => r.status === "PENDIENTE").length; + // Métricas + const clientes = users.filter(u => u.role === "CLIENTE").length; + const activos = users.filter(u => u.isActive).length; + const b2bPending = b2b.filter(r => r.status === "PENDIENTE").length; + const incidencias = packages.filter(p => p.status === "INCIDENCIA").length; + const enTransito = packages.filter(p => p.status === "EN_TRANSITO_ECUADOR").length; + const entregados = packages.filter(p => p.status === "ENTREGADO").length; + const pendientePago = packages.filter(p => p.status === "VERIFICADO" && !p.paidAt).length; + const ingresoTotal = payments + .filter(p => p.status === "COMPLETADO") + .reduce((s: number, p: any) => s + Number(p.amount), 0); + + // Paquetes por estado para mini-gráfico + const byStatus: Record = {}; + packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; }); return (
-

Dashboard Admin

-
+
+

Dashboard Admin

+

Vista ejecutiva — ingresos, operaciones y alertas del sistema.

+
+ + {/* Alertas críticas */} + {(incidencias > 0 || b2bPending > 0 || pendientePago > 0) && ( +
+ {incidencias > 0 && ( + + ⚠️ {incidencias} incidencia{incidencias !== 1 ? "s" : ""} abiertas + + )} + {b2bPending > 0 && ( +
+ 📋 {b2bPending} solicitud{b2bPending !== 1 ? "es" : ""} B2B pendiente{b2bPending !== 1 ? "s" : ""} +
+ )} + {pendientePago > 0 && ( + + 💳 {pendientePago} paquete{pendientePago !== 1 ? "s" : ""} pendiente{pendientePago !== 1 ? "s" : ""} de pago + + )} +
+ )} + + {/* KPIs principales */} +
{[ - { label: "Total usuarios", value: users.length, color: "var(--primary)" }, - { label: "Clientes", value: clientes, color: "var(--green)" }, - { label: "Solicitudes B2B", value: b2bPending, color: "var(--yellow)" }, - { label: "Activos", value: users.filter(u=>u.isActive).length, color: "var(--accent)" }, + { label: "Ingresos cobrados", value: `$${ingresoTotal.toFixed(2)}`, color: "var(--green)", icon: "💰" }, + { label: "Total paquetes", value: packages.length, color: "var(--primary)", icon: "📦" }, + { label: "Entregados", value: entregados, color: "var(--green)", icon: "✅" }, + { label: "En tránsito a EC", value: enTransito, color: "var(--accent)", icon: "✈️" }, + { label: "Clientes activos", value: clientes, color: "var(--primary)", icon: "👤" }, + { label: "Usuarios totales", value: activos, color: "var(--gray-600)", icon: "👥" }, + { label: "Incidencias", value: incidencias, color: "var(--red)", icon: "⚠️" }, + { label: "Pendiente de pago", value: pendientePago, color: "var(--yellow)", icon: "💳" }, ].map(s => (
-
{s.value}
+
{s.icon}
+
{s.value}
{s.label}
))}
+
+ {/* Distribución de paquetes por estado */}
-
- Últimos usuarios - Ver todos → +
+ Paquetes por estado + Reportes →
-
- - - - {users.slice(0,8).map(u => ( - - - - - - - ))} - -
NombreEmailRolEstado
{u.firstName} {u.lastName}{u.email}{u.role}{u.isActive ? "Activo" : "Inactivo"}
+
+ {[ + { status: "RECIBIDO_BODEGA", label: "Recibidos en bodega", color: "#3B82F6" }, + { status: "EN_VERIFICACION", label: "En verificación", color: "#F59E0B" }, + { status: "VERIFICADO", label: "Verificados", color: "#10B981" }, + { status: "DECLARACION_ADUANERA", label: "Declaración aduanera", color: "#0057FF" }, + { status: "EN_TRANSITO_ECUADOR", label: "En tránsito a Ecuador", color: "#F97316" }, + { status: "EN_ADUANA_ECUADOR", label: "En aduana Ecuador", color: "#EF4444" }, + { status: "ENTREGADO", label: "Entregados", color: "#10B981" }, + { status: "INCIDENCIA", label: "Incidencias", color: "#EF4444" }, + ].map(({ status, label, color }) => { + const count = byStatus[status] ?? 0; + const pct = packages.length ? Math.round((count / packages.length) * 100) : 0; + return ( +
+
+ {label} + {count} +
+
+
+
+
+ ); + })} + {packages.length === 0 &&

Sin paquetes aún.

}
-
-
Solicitudes B2B pendientes
- {b2bPending === 0 ? ( -

Sin solicitudes pendientes.

- ) : ( - b2b.filter(r=>r.status==="PENDIENTE").map(r => ( -
-
{r.companyName}
-
{r.contactEmail}
-
- )) - )} + + {/* Panel derecho */} +
+ {/* Usuarios recientes */} +
+
+ Usuarios recientes + Ver todos → +
+
+ + + + {users.slice(0, 5).map(u => ( + + + + + + ))} + +
NombreRolEstado
+
{u.firstName} {u.lastName}
+
{u.email}
+
{u.role}{u.isActive ? "Activo" : "Inactivo"}
+
+
+ + {/* Acciones rápidas */} +
+
Acciones rápidas
+
+ {[ + { href: "/admin/usuarios", icon: "👥", label: "Usuarios" }, + { href: "/admin/tarifas", icon: "💰", label: "Tarifas" }, + { href: "/admin/pagos", icon: "💳", label: "Cobros" }, + { href: "/admin/notificaciones", icon: "📋", label: "Plantillas" }, + { href: "/admin/reportes", icon: "📊", label: "Reportes" }, + { href: "/admin/auditoria", icon: "🔍", label: "Auditoría" }, + ].map(a => ( + { e.currentTarget.style.background = "var(--gray-100)"; }} + onMouseLeave={e => { e.currentTarget.style.background = "var(--gray-50)"; }} + > + {a.icon}{a.label} + + ))} +
+
diff --git a/apps/web/src/app/admin/pagos/page.tsx b/apps/web/src/app/admin/pagos/page.tsx new file mode 100644 index 0000000..48ea296 --- /dev/null +++ b/apps/web/src/app/admin/pagos/page.tsx @@ -0,0 +1,161 @@ +"use client"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +// Admin — Gestión de cobros / pagos del tenant + +const STATUS_LABEL: Record = { + PENDIENTE: "Pendiente", + PROCESANDO: "Procesando", + COMPLETADO: "Completado", + FALLIDO: "Fallido", + REEMBOLSADO: "Reembolsado", +}; + +const STATUS_BADGE: Record = { + PENDIENTE: "badge-yellow", + PROCESANDO: "badge-blue", + COMPLETADO: "badge-green", + FALLIDO: "badge-red", + REEMBOLSADO: "badge-gray", +}; + +export default function AdminPagosPage() { + const [payments, setPayments] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState("ALL"); + const [search, setSearch] = useState(""); + + const load = () => { + setLoading(true); + api.payments.list() + .then(setPayments) + .catch(() => {}) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); + + const filtered = payments.filter(p => { + const matchStatus = filter === "ALL" || p.status === filter; + const matchSearch = !search || + p.package?.trackingId?.toLowerCase().includes(search.toLowerCase()) || + p.providerRef?.toLowerCase().includes(search.toLowerCase()); + return matchStatus && matchSearch; + }); + + // Métricas + const total = payments.reduce((s, p) => s + Number(p.amount), 0); + const cobrado = payments.filter(p => p.status === "COMPLETADO").reduce((s, p) => s + Number(p.amount), 0); + const pendiente = payments.filter(p => p.status === "PENDIENTE").reduce((s, p) => s + Number(p.amount), 0); + const fallidos = payments.filter(p => p.status === "FALLIDO").length; + + if (loading) return ( +
+
+
+ ); + + return ( +
+
+

Gestión de Cobros

+

Historial de pagos vinculados a paquetes del tenant.

+
+ + {/* KPIs */} +
+ {[ + { label: "Total facturado", value: `$${total.toFixed(2)}`, color: "var(--primary)" }, + { label: "Cobrado", value: `$${cobrado.toFixed(2)}`, color: "var(--green)" }, + { label: "Pendiente cobro", value: `$${pendiente.toFixed(2)}`,color: "var(--yellow)" }, + { label: "Pagos fallidos", value: fallidos, color: "var(--red)" }, + ].map(s => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ + {/* Filtros */} +
+ setSearch(e.target.value)} + /> +
+ {["ALL", "PENDIENTE", "COMPLETADO", "FALLIDO", "REEMBOLSADO"].map(s => ( + + ))} +
+ + {filtered.length} registro{filtered.length !== 1 ? "s" : ""} + +
+ + {/* Tabla */} +
+
+ + + + + + + + + + + + + + {filtered.map(p => ( + + + + + + + + + + ))} + {filtered.length === 0 && ( + + + + )} + +
TrackingDescripciónProveedorReferenciaMontoEstadoFecha
+ {p.package?.trackingId ?? "—"} + + {p.package?.description ?? "—"} + {p.provider} + {p.providerRef ?? "—"} + ${Number(p.amount).toFixed(2)} + + {STATUS_LABEL[p.status] ?? p.status} + + + {p.paidAt + ? new Date(p.paidAt).toLocaleString("es-EC") + : new Date(p.createdAt).toLocaleDateString("es-EC")} +
+ Sin registros de pago. +
+
+
+
+ ); +} diff --git a/apps/web/src/app/aduanero/declaraciones/page.tsx b/apps/web/src/app/aduanero/declaraciones/page.tsx new file mode 100644 index 0000000..2cd240d --- /dev/null +++ b/apps/web/src/app/aduanero/declaraciones/page.tsx @@ -0,0 +1,211 @@ +"use client"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +// §11 — Declaraciones SENAE desde el portal del agente aduanero +// Misma lógica que /bodega/declaraciones pero accesible solo para AGENTE_ADUANERO + +const CATEGORIES = [ + { value: "REGIMEN_4X4", label: "Régimen 4×4 — 0% (≤$400, ≤4kg)" }, + { value: "CATEGORIA_B", label: "Categoría B — 10% (bienes generales)" }, + { value: "CATEGORIA_C", label: "Categoría C — 20% (textiles, calzado, hogar)" }, + { value: "CATEGORIA_D", label: "Categoría D — 0–15% (electrónicos)" }, +]; + +function autoCategory(pkg: any): string { + const value = parseFloat(pkg.declaredValue ?? 0); + const weightKg = parseFloat(pkg.actualWeight ?? pkg.declaredWeight ?? 0) * 0.453592; + if (value <= 400 && weightKg <= 4) return "REGIMEN_4X4"; + return "CATEGORIA_B"; +} + +export default function AduaneroDeclaracionesPage() { + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); + const [form, setForm] = useState({ category: "REGIMEN_4X4", agentNotes: "" }); + const [submitting, setSubmitting] = useState(false); + const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); + + const load = () => { + setLoading(true); + api.packages.pendingDeclaration() + .then(setPackages) + .catch(() => {}) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); + + const handleSelect = (pkg: any) => { + setSelected(pkg); + setForm({ category: autoCategory(pkg), agentNotes: "" }); + setMsg(null); + }; + + const handleDeclare = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selected) return; + setSubmitting(true); + setMsg(null); + try { + const result = await api.packages.senaeDeclare(selected.id, form); + setMsg({ type: "success", text: `DSI generada. N° Autorización SENAE: ${result.authNumber ?? result.senaeAuthNumber ?? "STUB-" + Date.now()}` }); + setSelected(null); + load(); + } catch (err: any) { + setMsg({ type: "error", text: err.message ?? "Error al generar declaración" }); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

Declaraciones SENAE

+

+ Cola de paquetes verificados pendientes de DSI. Genera la Declaración Simplificada (DSI) o inicia el proceso formal (DAI). §11 +

+
+ + {msg && ( +
+ {msg.type === "success" ? "✅ " : "❌ "}{msg.text} +
+ )} + +
+ {/* Cola */} +
+
+ Pendientes de declaración + {packages.length} +
+ {loading ? ( +
+ ) : packages.length === 0 ? ( +
+
🎉
+

No hay paquetes pendientes de declaración.

+

+ Los paquetes en estado VERIFICADO aparecerán aquí. +

+
+ ) : ( +
+ + + + + + {packages.map(p => { + const supera4x4 = parseFloat(p.declaredValue ?? 0) > 400; + return ( + handleSelect(p)} + style={{ cursor: "pointer", background: selected?.id === p.id ? "var(--blue-50, #EFF6FF)" : undefined }} + > + + + + + + ); + })} + +
TrackingValorPesoRégimen
{p.trackingId} + ${p.declaredValue} + {p.actualWeight ? `${p.actualWeight}lb` : "—"} + + {supera4x4 ? "DAI" : "4×4"} + +
+
+ )} +
+ + {/* Formulario DSI */} + {selected ? ( +
+
+ Generar DSI — {selected.trackingId} +
+
+ {/* Resumen */} +
+
+
Descripción: {selected.description}
+
Tienda: {selected.store ?? "—"}
+
+ Valor:{" "} + 400 ? "var(--red)" : "var(--green)", fontWeight: 700 }}> + ${selected.declaredValue} + +
+
Peso real: {selected.actualWeight ? `${selected.actualWeight}lb` : selected.declaredWeight ? `~${selected.declaredWeight}lb` : "—"}
+
Cliente: {selected.user?.firstName ?? "—"} {selected.user?.lastName ?? ""}
+
+ {parseFloat(selected.declaredValue ?? 0) > 400 && ( +
+ ⚠️ Supera $400 — proceso DAI obligatorio. Coordinar con importador. +
+ )} +
+ +
+
+ + +
+
+ +