feat: consolidations, aduanero portal, payments admin, bug fixes, tests

## Bugs corregidos
- bodega/page.tsx: estados correctos §08 (RECIBIDO_BODEGA, EN_VERIFICACION, EN_TRANSITO_ECUADOR)
- admin/page.tsx: carga paquetes reales, alertas de incidencias/B2B/cobros pendientes, barras de estado

## Nuevos portales y páginas
- /aduanero/ — portal propio para AGENTE_ADUANERO (layout + dashboard §11 + declaraciones DSI)
- /aduanero/declaraciones — cola de declaraciones con formulario DSI y detección DAI automática
- /admin/pagos — gestión de cobros: KPIs, filtros por estado, tabla con breakdown
- /portal/consolidacion — cliente crea/gestiona consolidaciones §21
- /bodega/consolidacion — operador cierra y despacha consolidaciones (→ EN_TRANSITO_ECUADOR)

## API nueva (ConsolidationsModule)
- GET/POST /consolidations
- GET /consolidations/:id
- POST /consolidations/:id/packages
- DELETE /consolidations/:id/packages/:packageId
- POST /consolidations/:id/close
- POST /consolidations/:id/dispatch (→ actualiza paquetes a EN_TRANSITO_ECUADOR)

## Prisma schema v0.5
- ConsolidationStatus enum (ABIERTA, CERRADA, DESPACHADA, ENTREGADA, CANCELADA)
- Consolidation model con totales calculados (totalWeightLb, totalValue)
- ConsolidationPackage (tabla intermedia, un paquete = una consolidación)
- db push aplicado a remote DB (46.202.93.92)

## Navegación
- Login: AGENTE_ADUANERO → /aduanero (ya no /bodega)
- Bodega nav: añadido Consolidaciones
- Portal nav: añadido Consolidar
- Admin nav: añadido Cobros

## Tests (104 total, 8 suites)
- payments.service.spec.ts: 16 tests (createIntent, confirm, list, findByPackageForUser)
- notifications.service.spec.ts: 12 tests (getTemplates, updateTemplate, seedDefaultTemplates, notifyStatusChange, findByUser)

## Legal §21
- Registro: aviso LOPDP Ecuador + normativa NJ en footer del formulario
- Landing footer: aviso detallado de protección de datos LOPDP/NJ

## api.ts: consolidations.* client methods
This commit is contained in:
Lizandro Guarnizo
2026-06-01 18:29:26 -05:00
parent 5565eef554
commit 7682b11dff
23 changed files with 2158 additions and 95 deletions
+2
View File
@@ -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 {}
@@ -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<any[]> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
return this.svc.dispatch(id, req.user.tenantId, dto.courierTracking);
}
}
@@ -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 {}
@@ -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<any[]> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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 } },
},
},
},
});
}
}
@@ -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>(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,
})
);
});
});
});
@@ -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>(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);
});
});
});