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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user